···13131414**[place.wisp.v2.wh](/lexicons/place-wisp-wh)** — webhook record for receiving HTTP callbacks when AT Protocol records change.
15151616+**place.wisp.v2.secret.{create,list,delete,rotate}** — server-managed signing secrets for webhooks. Tokens are returned once at creation and never stored in plaintext. See the [webhooks doc](/lexicons/place-wisp-wh#signing-secrets-api) for usage.
1717+1618## Storage Model
17191820Sites are stored as `place.wisp.fs` records in your AT Protocol repository:
+54-18
docs/src/content/docs/lexicons/place-wisp-wh.md
···23232424**Events** can be filtered to `create`, `update`, `delete`, or any combination. Omit the filter to receive all three.
25252626-**Secret** — if set, every delivery includes an `X-Webhook-Signature` header for verification.
2626+**Signing** — attach a signing secret to get an `X-Webhook-Signature` header on every delivery. Two options:
2727+2828+- `secret` — embed a plaintext value directly in your PDS record. Simple, but the secret is stored in your public repo.
2929+- `secretId` — reference a server-managed secret by name (created via `place.wisp.v2.secret.create`). The token is never stored in your PDS record and can be rotated without updating the webhook. **Prefer this.**
27302831## Payload
2932···53565457## Verifying Signatures
55585656-If you set a secret, verify the `X-Webhook-Signature` header using HMAC-SHA256:
5959+If a signing secret is set (via `secret` or `secretId`), every delivery includes an `X-Webhook-Signature: sha256=<hex>` header. Verify it using HMAC-SHA256 over the **raw request body**:
57605861```typescript
5962import { createHmac, timingSafeEqual } from 'crypto'
···6467}
6568```
66696767-Always use a timing-safe comparison. Compute the HMAC over the raw request body before parsing.
7070+Always use a timing-safe comparison. Compute the HMAC before parsing the body.
68716972## Delivery
7073···8588 },
8689 "url": "https://example.com/webhook",
8790 "events": ["create", "update"],
8888- "secret": "your-hmac-secret",
9191+ "secretId": "my-secret",
8992 "enabled": true,
9093 "createdAt": "2024-01-15T10:30:00.000Z"
9194}
9295```
93969797+| Field | Type | Description |
9898+|---|---|---|
9999+| `scope.aturi` | string | AT-URI to watch |
100100+| `scope.backlinks` | boolean | Also fire when other repos reference this scope |
101101+| `url` | string | HTTPS endpoint to deliver to |
102102+| `events` | string[] | `create`, `update`, `delete` — omit for all three |
103103+| `secretId` | string | Name of a server-managed signing secret (preferred) |
104104+| `secret` | string | Inline HMAC secret (stored plaintext in PDS) |
105105+| `enabled` | boolean | Set to `false` to pause delivery |
106106+94107## API Convenience Routes
9510896109The main app exposes API routes that wrap PDS record operations. All routes require the signed `did` cookie.
···101114102115### `POST /api/webhook`
103116104104-Creates a new webhook record. Body matches the `place.wisp.v2.wh` record shape.
117117+Creates a new webhook record. Body:
118118+119119+```json
120120+{
121121+ "scopeAturi": "at://did:plc:abc123/app.bsky.feed.post",
122122+ "url": "https://example.com/webhook",
123123+ "backlinks": false,
124124+ "events": ["create"],
125125+ "secretId": "my-secret",
126126+ "enabled": true
127127+}
128128+```
105129106130### `DELETE /api/webhook/:rkey`
107131···111135112136Returns the last 100 delivery events for the authenticated user.
113137138138+## Signing Secrets API
139139+140140+Server-managed secrets are never stored in your PDS — the token is returned once at creation time and then only stored as a hash. Manage them via:
141141+142142+### `GET /api/secret`
143143+144144+Lists all secrets (names and metadata only — tokens are never returned after creation).
145145+146146+### `POST /api/secret`
147147+148148+Creates a new secret. Body: `{ "name": "my-secret" }`.
149149+150150+Response includes `token` — **copy it now**, it will not be shown again.
151151+114152```json
115115-[
116116- {
117117- "id": "...",
118118- "rkey": "abc123",
119119- "url": "https://example.com/webhook",
120120- "event_kind": "create",
121121- "event_did": "did:plc:...",
122122- "event_collection": "app.bsky.feed.post",
123123- "event_rkey": "3kl2jd9s8f7g",
124124- "status": "ok",
125125- "delivered_at": "2024-01-15T10:30:00.000Z"
126126- }
127127-]
153153+{ "success": true, "name": "my-secret", "token": "wsk_...", "createdAt": "..." }
128154```
155155+156156+### `POST /api/secret/:name/rotate`
157157+158158+Generates a new token for an existing secret. The old token stops working immediately. Returns the new `token` once.
159159+160160+### `DELETE /api/secret/:name`
161161+162162+Deletes a secret. Any webhooks referencing this `secretId` will stop being signed.
163163+164164+These routes are also available as XRPC procedures under `place.wisp.v2.secret.*` for programmatic access with a service JWT.
129165130166## Self-Hosting
131167
+88
docs/src/content/docs/reference/xrpc-api.md
···237237```
238238239239**Errors:** `AuthenticationRequired`, `InvalidRequest`, `NotFound`
240240+241241+---
242242+243243+## Signing Secrets
244244+245245+Server-managed HMAC signing secrets for webhooks. The token is returned **once** at creation time and never stored in plaintext — it cannot be retrieved again, only rotated.
246246+247247+All four endpoints require authentication (`AuthenticationRequired` on failure).
248248+249249+### `place.wisp.v2.secret.create` — procedure 🔒
250250+251251+Creates a new signing secret scoped to the authenticated DID.
252252+253253+**Input:**
254254+255255+| Field | Type | Required | Notes |
256256+|---|---|---|---|
257257+| `name` | `string` (record-key) | ✅ | Unique per DID, `a-z0-9-` |
258258+259259+**Response:**
260260+261261+| Field | Type | Notes |
262262+|---|---|---|
263263+| `name` | `string` | |
264264+| `token` | `string` | `wsk_` prefixed — store this now, never shown again |
265265+| `createdAt` | `string` (datetime) | |
266266+267267+**Errors:** `AuthenticationRequired`, `InvalidRequest`, `AlreadyExists`
268268+269269+---
270270+271271+### `place.wisp.v2.secret.list` — query 🔒
272272+273273+Lists all secrets for the authenticated DID. Token values are never returned.
274274+275275+**Response:**
276276+277277+```json
278278+{
279279+ "secrets": [
280280+ {
281281+ "name": "my-secret",
282282+ "createdAt": "2024-01-15T10:30:00.000Z",
283283+ "lastRotatedAt": "2024-02-01T09:00:00.000Z"
284284+ }
285285+ ]
286286+}
287287+```
288288+289289+**Errors:** `AuthenticationRequired`
290290+291291+---
292292+293293+### `place.wisp.v2.secret.rotate` — procedure 🔒
294294+295295+Generates a new token for an existing secret. The old token is invalidated immediately.
296296+297297+**Input:**
298298+299299+| Field | Type | Required |
300300+|---|---|---|
301301+| `name` | `string` | ✅ |
302302+303303+**Response:**
304304+305305+| Field | Type | Notes |
306306+|---|---|---|
307307+| `name` | `string` | |
308308+| `token` | `string` | New token — store this now, never shown again |
309309+| `rotatedAt` | `string` (datetime) | |
310310+311311+**Errors:** `AuthenticationRequired`, `NotFound`
312312+313313+---
314314+315315+### `place.wisp.v2.secret.delete` — procedure 🔒
316316+317317+Deletes a signing secret. Any webhooks referencing this `secretId` will stop being signed.
318318+319319+**Input:**
320320+321321+| Field | Type | Required |
322322+|---|---|---|
323323+| `name` | `string` | ✅ |
324324+325325+**Response:** `{}`
326326+327327+**Errors:** `AuthenticationRequired`, `NotFound`