place.wisp.v2.wh
Webhooks let you receive HTTP POST notifications when AT Protocol records are created, updated, or deleted. They’re scoped to an AT-URI — you can watch a specific record, an entire collection, or everything from a DID.
Webhooks are stored as place.wisp.v2.wh records in your AT Protocol repository. The webhook service watches the firehose for place.wisp.v2.wh record changes, reads the full record back from your PDS (read-after-write), then begins delivering matching events to your URL.
Creating a Webhook
Section titled “Creating a Webhook”Create a webhook by writing a place.wisp.v2.wh record to your PDS, or by using the Webhooks tab in the editor. The record schema is below.
Scope controls what you’re watching:
| Scope AT-URI | Watches |
|---|---|
at://did:plc:abc | All record changes from that DID |
at://did:plc:abc/app.bsky.feed.post | All posts from that DID |
at://did:plc:abc/app.bsky.feed.post/rkey | One specific record |
Enable backlinks to also fire when records in any repo reference your DID or collection — useful for watching Bluesky likes, Tangled pull requests, etc directed at you.
Events can be filtered to create, update, delete, or any combination. Omit the filter to receive all three.
Secret — if set, every delivery includes an X-Webhook-Signature header for verification.
Payload
Section titled “Payload”Each delivery is an HTTP POST with Content-Type: application/json:
{ "id": "550e8400-e29b-41d4-a716-446655440000", "event": "create", "did": "did:plc:abc123", "collection": "app.bsky.feed.post", "rkey": "3kl2jd9s8f7g", "cid": "bafyreiabc...", "record": { ... }, "timestamp": "2024-01-15T10:30:00.000Z"}record is the full record body and is absent on delete events.
Headers:
User-Agent: wisp.place-webhook/1.0X-Webhook-Signature: sha256=<hex> (only if secret is set)Verifying Signatures
Section titled “Verifying Signatures”If you set a secret, verify the X-Webhook-Signature header using HMAC-SHA256:
import { createHmac, timingSafeEqual } from 'crypto'
function verifySignature(body: string, secret: string, header: string): boolean { const expected = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex') return timingSafeEqual(Buffer.from(header), Buffer.from(expected))}Always use a timing-safe comparison. Compute the HMAC over the raw request body before parsing.
Delivery
Section titled “Delivery”The webhook service delivers with a 10 second timeout and retries up to 3 times with exponential backoff on failure. Your endpoint should return a 2xx response quickly — do any heavy processing asynchronously.
Delivery attempts are logged and visible in the editor under each webhook’s event history (last 500 events per webhook).
Record Schema
Section titled “Record Schema”Webhooks are stored as place.wisp.v2.wh records in your PDS:
{ "$type": "place.wisp.v2.wh", "scope": { "aturi": "at://did:plc:abc123/app.bsky.feed.post", "backlinks": false }, "url": "https://example.com/webhook", "events": ["create", "update"], "secret": "your-hmac-secret", "enabled": true, "createdAt": "2024-01-15T10:30:00.000Z"}API Convenience Routes
Section titled “API Convenience Routes”The main app exposes API routes that wrap PDS record operations. All routes require the signed did cookie.
GET /api/webhook
Section titled “GET /api/webhook”Lists all webhook records for the authenticated user.
POST /api/webhook
Section titled “POST /api/webhook”Creates a new webhook record. Body matches the place.wisp.v2.wh record shape.
DELETE /api/webhook/:rkey
Section titled “DELETE /api/webhook/:rkey”Deletes a webhook record by its record key.
GET /api/webhook/events
Section titled “GET /api/webhook/events”Returns the last 100 delivery events for the authenticated user.
[ { "id": "...", "rkey": "abc123", "url": "https://example.com/webhook", "event_kind": "create", "event_did": "did:plc:...", "event_collection": "app.bsky.feed.post", "event_rkey": "3kl2jd9s8f7g", "status": "ok", "delivered_at": "2024-01-15T10:30:00.000Z" }]Self-Hosting
Section titled “Self-Hosting”The webhook service is a separate Bun process in apps/webhook-service.
DATABASE_URL="postgres://user:password@localhost:5432/wisp"JETSTREAM_URL="wss://jetstream2.us-east.bsky.network/subscribe"HEALTH_PORT=3003DELIVERY_TIMEOUT_MS=10000DELIVERY_MAX_RETRIES=3REDIS_URL="redis://localhost:6379"WEBHOOK_EVENTS_CHANNEL="webhook:events"