Monorepo for wisp.place. A static site hosting service built on top of the AT Protocol. wisp.place
87
fork

Configure Feed

Select the types of activity you want to include in your feed.

more work

+285 -126
+2 -9
apps/main-app/public/editor/editor.tsx
··· 52 52 createWebhook, 53 53 deleteWebhook, 54 54 } = useWebhookData() 55 - const { 56 - secrets, 57 - secretsLoading, 58 - isCreatingSecret, 59 - fetchSecrets, 60 - createSecret, 61 - deleteSecret, 62 - rotateSecret, 63 - } = useSecretData() 55 + const { secrets, secretsLoading, isCreatingSecret, fetchSecrets, createSecret, deleteSecret, rotateSecret } = 56 + useSecretData() 64 57 65 58 const { 66 59 wispDomains,
+1 -3
apps/main-app/public/editor/hooks/useSecretData.ts
··· 65 65 }) 66 66 const data = await res.json() 67 67 if (!res.ok) throw new Error(data.error || 'Failed to rotate secret') 68 - setSecrets((prev) => 69 - prev.map((s) => (s.name === name ? { ...s, lastRotatedAt: data.rotatedAt } : s)), 70 - ) 68 + setSecrets((prev) => prev.map((s) => (s.name === name ? { ...s, lastRotatedAt: data.rotatedAt } : s))) 71 69 return { token: data.token } 72 70 }, []) 73 71
+14 -9
apps/main-app/public/editor/hooks/useWebhookData.ts
··· 8 8 events: string[] 9 9 enabled: boolean 10 10 createdAt: string 11 + secretId?: string 11 12 } 12 13 13 14 export interface WebhookEventLog { ··· 38 39 const data = await res.json() 39 40 if (data.success && data.records) { 40 41 setWebhooks( 41 - data.records.map((r: any) => ({ 42 - rkey: r.uri.split('/').pop(), 43 - scopeAturi: r.value?.scope?.aturi ?? '', 44 - url: r.value?.url ?? '', 45 - backlinks: r.value?.scope?.backlinks ?? false, 46 - events: r.value?.events ?? [], 47 - enabled: r.value?.enabled ?? true, 48 - createdAt: r.value?.createdAt ?? '', 49 - })), 42 + data.records.map((r: { uri: string; value?: Record<string, unknown> }) => { 43 + const scope = r.value?.scope as Record<string, unknown> | undefined 44 + return { 45 + rkey: r.uri.split('/').pop() ?? '', 46 + scopeAturi: (scope?.aturi as string) ?? '', 47 + url: (r.value?.url as string) ?? '', 48 + backlinks: (scope?.backlinks as boolean) ?? false, 49 + events: (r.value?.events as string[]) ?? [], 50 + enabled: (r.value?.enabled as boolean) ?? true, 51 + createdAt: (r.value?.createdAt as string) ?? '', 52 + secretId: r.value?.secretId as string | undefined, 53 + } 54 + }), 50 55 ) 51 56 } 52 57 } catch (err) {
+7 -3
apps/main-app/public/editor/tabs/WebhooksTab.tsx
··· 737 737 backlinks 738 738 </Badge> 739 739 )} 740 + {wh.secretId && ( 741 + <Badge variant="outline" className="text-[10px] gap-1"> 742 + <KeyRound className="w-2.5 h-2.5" /> 743 + {wh.secretId} 744 + </Badge> 745 + )} 740 746 {wh.events.length > 0 ? ( 741 747 wh.events.map((e) => ( 742 748 <Badge key={e} variant="outline" className="text-[10px]"> ··· 832 838 </Button> 833 839 </div> 834 840 835 - {secretError && ( 836 - <p className="text-xs text-destructive">{secretError}</p> 837 - )} 841 + {secretError && <p className="text-xs text-destructive">{secretError}</p>} 838 842 839 843 {/* Revealed token — show once */} 840 844 {revealedToken && (
+3 -3
apps/main-app/src/lib/db.ts
··· 526 526 } 527 527 528 528 export const deleteWebhookSecret = async (did: string, name: string): Promise<boolean> => { 529 - const result = await db` 530 - DELETE FROM webhook_secrets WHERE did = ${did} AND name = ${name} 529 + const rows = await db` 530 + DELETE FROM webhook_secrets WHERE did = ${did} AND name = ${name} RETURNING name 531 531 ` 532 - return (result as any).count > 0 532 + return rows.length > 0 533 533 } 534 534 535 535 export const rotateWebhookSecret = async (
+36 -56
apps/main-app/src/routes/xrpc.ts
··· 808 808 }, 809 809 ) 810 810 811 - addProcedureWithAliases( 812 - router, 813 - withNsid(PlaceWispV2SecretCreate.mainSchema as any, XRPC_NSIDS.secretCreate), 814 - [], 815 - { 816 - async handler({ input, request }) { 817 - const auth = requireAuthenticated(authByRequest.get(request)) 818 - const name = input.name?.trim() 819 - if (!name) invalidRequest('name is required') 820 - try { 821 - const { token, createdAt } = await createWebhookSecret(auth.did, name!) 822 - return json({ name: name!, token, createdAt }) 823 - } catch { 824 - return alreadyExists('a secret with that name already exists') 825 - } 826 - }, 811 + addProcedureWithAliases(router, withNsid(PlaceWispV2SecretCreate.mainSchema as any, XRPC_NSIDS.secretCreate), [], { 812 + async handler({ input, request }) { 813 + const auth = requireAuthenticated(authByRequest.get(request)) 814 + const name = input.name?.trim() 815 + if (!name) invalidRequest('name is required') 816 + try { 817 + const { token, createdAt } = await createWebhookSecret(auth.did, name!) 818 + return json({ name: name!, token, createdAt }) 819 + } catch { 820 + return alreadyExists('a secret with that name already exists') 821 + } 827 822 }, 828 - ) 823 + }) 829 824 830 - addQueryWithAliases( 831 - router, 832 - withNsid(PlaceWispV2SecretList.mainSchema as any, XRPC_NSIDS.secretList), 833 - [], 834 - { 835 - async handler({ request }) { 836 - const auth = requireAuthenticated(authByRequest.get(request)) 837 - const secrets = await listWebhookSecrets(auth.did) 838 - return json({ secrets }) 839 - }, 825 + addQueryWithAliases(router, withNsid(PlaceWispV2SecretList.mainSchema as any, XRPC_NSIDS.secretList), [], { 826 + async handler({ request }) { 827 + const auth = requireAuthenticated(authByRequest.get(request)) 828 + const secrets = await listWebhookSecrets(auth.did) 829 + return json({ secrets }) 840 830 }, 841 - ) 831 + }) 842 832 843 - addProcedureWithAliases( 844 - router, 845 - withNsid(PlaceWispV2SecretDelete.mainSchema as any, XRPC_NSIDS.secretDelete), 846 - [], 847 - { 848 - async handler({ input, request }) { 849 - const auth = requireAuthenticated(authByRequest.get(request)) 850 - const name = input.name?.trim() 851 - if (!name) invalidRequest('name is required') 852 - const deleted = await deleteWebhookSecret(auth.did, name) 853 - if (!deleted) notFound('secret not found') 854 - return json({}) 855 - }, 833 + addProcedureWithAliases(router, withNsid(PlaceWispV2SecretDelete.mainSchema as any, XRPC_NSIDS.secretDelete), [], { 834 + async handler({ input, request }) { 835 + const auth = requireAuthenticated(authByRequest.get(request)) 836 + const name = input.name?.trim() 837 + if (!name) invalidRequest('name is required') 838 + const deleted = await deleteWebhookSecret(auth.did, name) 839 + if (!deleted) notFound('secret not found') 840 + return json({}) 856 841 }, 857 - ) 842 + }) 858 843 859 - addProcedureWithAliases( 860 - router, 861 - withNsid(PlaceWispV2SecretRotate.mainSchema as any, XRPC_NSIDS.secretRotate), 862 - [], 863 - { 864 - async handler({ input, request }) { 865 - const auth = requireAuthenticated(authByRequest.get(request)) 866 - const name = input.name?.trim() 867 - if (!name) invalidRequest('name is required') 868 - const result = await rotateWebhookSecret(auth.did, name) 869 - if (!result) notFound('secret not found') 870 - return json({ name, token: result!.token, rotatedAt: result!.rotatedAt }) 871 - }, 844 + addProcedureWithAliases(router, withNsid(PlaceWispV2SecretRotate.mainSchema as any, XRPC_NSIDS.secretRotate), [], { 845 + async handler({ input, request }) { 846 + const auth = requireAuthenticated(authByRequest.get(request)) 847 + const name = input.name?.trim() 848 + if (!name) invalidRequest('name is required') 849 + const result = await rotateWebhookSecret(auth.did, name) 850 + if (!result) notFound('secret not found') 851 + return json({ name, token: result!.token, rotatedAt: result!.rotatedAt }) 872 852 }, 873 - ) 853 + }) 874 854 875 855 const schemaNsids = { 876 856 addSite: (PlaceWispV2DomainAddSite.mainSchema as any).nsid,
+78 -25
apps/webhook-service/bench/e2e.ts
··· 19 19 20 20 import { createHmac } from 'node:crypto' 21 21 import type { Main as WhRecord } from '@wispplace/lexicons/types/place/wisp/v2/wh' 22 + import { 23 + db, 24 + deleteWebhookRecord, 25 + findBacklinkWebhooks, 26 + findWebhooksForDid, 27 + getWebhookSecretToken, 28 + upsertWebhookRecord, 29 + } from '../src/lib/db' 30 + import { deliverWebhook } from '../src/lib/delivery' 22 31 import { JetstreamClient } from '../src/lib/jetstream' 23 - import { db, deleteWebhookRecord, findBacklinkWebhooks, findWebhooksForDid, getWebhookSecretToken, upsertWebhookRecord } from '../src/lib/db' 24 32 import { matchWebhooks } from '../src/lib/matcher' 25 - import { deliverWebhook } from '../src/lib/delivery' 26 33 27 34 // --------------------------------------------------------------------------- 28 35 // Config ··· 95 102 return res.json() as Promise<{ uri: string; cid: string }> 96 103 } 97 104 98 - async function deleteRecord(pdsUrl: string, jwt: string, repo: string, collection: string, rkey: string): Promise<void> { 105 + async function deleteRecord( 106 + pdsUrl: string, 107 + jwt: string, 108 + repo: string, 109 + collection: string, 110 + rkey: string, 111 + ): Promise<void> { 99 112 const res = await fetch(`${pdsUrl}/xrpc/com.atproto.repo.deleteRecord`, { 100 113 method: 'POST', 101 114 headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${jwt}` }, ··· 122 135 const signature = req.headers.get('x-webhook-signature') ?? undefined 123 136 const body = JSON.parse(rawBody) 124 137 deliveries.push({ ts: Date.now(), body, signature, rawBody }) 125 - console.log(` [delivery #${deliveries.length}] sig=${signature ?? 'none'} ${JSON.stringify(body).slice(0, 80)}`) 138 + console.log( 139 + ` [delivery #${deliveries.length}] sig=${signature ?? 'none'} ${JSON.stringify(body).slice(0, 80)}`, 140 + ) 126 141 if (deliveries.length >= expectedDeliveries) resolveDelivery?.() 127 142 return new Response('ok') 128 143 }, ··· 136 151 if (deliveries.length >= n) return Promise.resolve() 137 152 return new Promise<void>((resolve, reject) => { 138 153 resolveDelivery = resolve 139 - setTimeout(() => reject(new Error(`Timed out waiting for ${n} deliveries (got ${deliveries.length})`)), EVENT_TIMEOUT_MS) 154 + setTimeout( 155 + () => reject(new Error(`Timed out waiting for ${n} deliveries (got ${deliveries.length})`)), 156 + EVENT_TIMEOUT_MS, 157 + ) 140 158 }) 141 159 } 142 160 ··· 146 164 147 165 const testDid: { value: string } = { value: '' } 148 166 let resolveWhRegistered: (() => void) | null = null 149 - const whRegistered = new Promise<void>((resolve) => { resolveWhRegistered = resolve }) 167 + const whRegistered = new Promise<void>((resolve) => { 168 + resolveWhRegistered = resolve 169 + }) 150 170 151 - async function handleEvent(event: { kind: string; did: string; commit?: { operation: string; collection: string; rkey: string; record?: unknown; cid?: string } }) { 171 + async function handleEvent(event: { 172 + kind: string 173 + did: string 174 + commit?: { operation: string; collection: string; rkey: string; record?: unknown; cid?: string } 175 + }) { 152 176 if (event.kind !== 'commit' || !event.commit) return 153 177 const { did } = event 154 178 const { operation: op, collection, rkey, record, cid } = event.commit ··· 177 201 const candidates = [...directCandidates] 178 202 for (const entry of backlinkCandidates) { 179 203 const k = `${entry.ownerDid}/${entry.rkey}` 180 - if (!seen.has(k)) { seen.add(k); candidates.push(entry) } 204 + if (!seen.has(k)) { 205 + seen.add(k) 206 + candidates.push(entry) 207 + } 181 208 } 182 209 183 210 if (candidates.length === 0) return ··· 248 275 console.log('--- step 1: create place.wisp.v2.wh ---') 249 276 const t0 = Date.now() 250 277 const whRkey = `bench-wh-${Date.now()}` 251 - const { uri: whUri } = await createRecord(pdsUrl, session.accessJwt, session.did, 'place.wisp.v2.wh', { 252 - $type: 'place.wisp.v2.wh', 253 - scope: { $type: 'place.wisp.v2.wh#atUri', aturi: scopeAturi, backlinks: true }, 254 - url: deliveryUrl, 255 - events: ['create'], 256 - enabled: true, 257 - createdAt: new Date().toISOString(), 258 - }, whRkey) 278 + const { uri: whUri } = await createRecord( 279 + pdsUrl, 280 + session.accessJwt, 281 + session.did, 282 + 'place.wisp.v2.wh', 283 + { 284 + $type: 'place.wisp.v2.wh', 285 + scope: { $type: 'place.wisp.v2.wh#atUri', aturi: scopeAturi, backlinks: true }, 286 + url: deliveryUrl, 287 + events: ['create'], 288 + enabled: true, 289 + createdAt: new Date().toISOString(), 290 + }, 291 + whRkey, 292 + ) 259 293 createdRecords.push({ collection: 'place.wisp.v2.wh', rkey: whRkey }) 260 294 console.log(` created ${whUri}`) 261 295 262 296 await Promise.race([ 263 297 whRegistered, 264 - new Promise((_, reject) => setTimeout(() => reject(new Error('Timed out waiting for wh to be registered in DB')), EVENT_TIMEOUT_MS)), 298 + new Promise((_, reject) => 299 + setTimeout(() => reject(new Error('Timed out waiting for wh to be registered in DB')), EVENT_TIMEOUT_MS), 300 + ), 265 301 ]) 266 302 console.log(` registered in DB in ${Date.now() - t0}ms\n`) 267 303 268 304 // 4. Create app.bsky.feed.post → direct match 269 305 console.log('--- step 2: create post (expect delivery #1 — direct match) ---') 270 306 const t1 = Date.now() 271 - const { uri: postUri, cid: postCid } = await createRecord(pdsUrl, session.accessJwt, session.did, 'app.bsky.feed.post', { 272 - $type: 'app.bsky.feed.post', 273 - text: 'wisp webhook e2e test post', 274 - createdAt: new Date().toISOString(), 275 - }) 307 + const { uri: postUri, cid: postCid } = await createRecord( 308 + pdsUrl, 309 + session.accessJwt, 310 + session.did, 311 + 'app.bsky.feed.post', 312 + { 313 + $type: 'app.bsky.feed.post', 314 + text: 'wisp webhook e2e test post', 315 + createdAt: new Date().toISOString(), 316 + }, 317 + ) 276 318 const postRkey = postUri.split('/').at(-1)! 277 319 createdRecords.push({ collection: 'app.bsky.feed.post', rkey: postRkey }) 278 320 console.log(` created ${postUri}`) ··· 322 364 const beforeCount = deliveries.length 323 365 await deliverWebhook( 324 366 { ownerDid: session.did, rkey: signedWhRkey, record: signedWh }, 325 - session.did, 'app.bsky.feed.post', 'test-rkey', 'create', undefined, { text: 'signed test' }, 367 + session.did, 368 + 'app.bsky.feed.post', 369 + 'test-rkey', 370 + 'create', 371 + undefined, 372 + { text: 'signed test' }, 326 373 ) 327 374 328 375 // Wait for it 329 376 await new Promise<void>((resolve, reject) => { 330 - const check = () => { if (deliveries.length > beforeCount) resolve() } 377 + const check = () => { 378 + if (deliveries.length > beforeCount) resolve() 379 + } 331 380 check() 332 381 const iv = setInterval(check, 50) 333 - setTimeout(() => { clearInterval(iv); reject(new Error('Timed out waiting for signed delivery')) }, EVENT_TIMEOUT_MS) 382 + setTimeout(() => { 383 + clearInterval(iv) 384 + reject(new Error('Timed out waiting for signed delivery')) 385 + }, EVENT_TIMEOUT_MS) 334 386 }) 335 387 336 388 const signedDelivery = deliveries.at(-1)! ··· 357 409 } 358 410 js.destroy() 359 411 deliveryServer.stop(true) 412 + await db.close() 360 413 } 361 414 } 362 415
+2
docs/src/content/docs/lexicons/index.md
··· 13 13 14 14 **[place.wisp.v2.wh](/lexicons/place-wisp-wh)** — webhook record for receiving HTTP callbacks when AT Protocol records change. 15 15 16 + **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. 17 + 16 18 ## Storage Model 17 19 18 20 Sites are stored as `place.wisp.fs` records in your AT Protocol repository:
+54 -18
docs/src/content/docs/lexicons/place-wisp-wh.md
··· 23 23 24 24 **Events** can be filtered to `create`, `update`, `delete`, or any combination. Omit the filter to receive all three. 25 25 26 - **Secret** — if set, every delivery includes an `X-Webhook-Signature` header for verification. 26 + **Signing** — attach a signing secret to get an `X-Webhook-Signature` header on every delivery. Two options: 27 + 28 + - `secret` — embed a plaintext value directly in your PDS record. Simple, but the secret is stored in your public repo. 29 + - `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.** 27 30 28 31 ## Payload 29 32 ··· 53 56 54 57 ## Verifying Signatures 55 58 56 - If you set a secret, verify the `X-Webhook-Signature` header using HMAC-SHA256: 59 + 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**: 57 60 58 61 ```typescript 59 62 import { createHmac, timingSafeEqual } from 'crypto' ··· 64 67 } 65 68 ``` 66 69 67 - Always use a timing-safe comparison. Compute the HMAC over the raw request body before parsing. 70 + Always use a timing-safe comparison. Compute the HMAC before parsing the body. 68 71 69 72 ## Delivery 70 73 ··· 85 88 }, 86 89 "url": "https://example.com/webhook", 87 90 "events": ["create", "update"], 88 - "secret": "your-hmac-secret", 91 + "secretId": "my-secret", 89 92 "enabled": true, 90 93 "createdAt": "2024-01-15T10:30:00.000Z" 91 94 } 92 95 ``` 93 96 97 + | Field | Type | Description | 98 + |---|---|---| 99 + | `scope.aturi` | string | AT-URI to watch | 100 + | `scope.backlinks` | boolean | Also fire when other repos reference this scope | 101 + | `url` | string | HTTPS endpoint to deliver to | 102 + | `events` | string[] | `create`, `update`, `delete` — omit for all three | 103 + | `secretId` | string | Name of a server-managed signing secret (preferred) | 104 + | `secret` | string | Inline HMAC secret (stored plaintext in PDS) | 105 + | `enabled` | boolean | Set to `false` to pause delivery | 106 + 94 107 ## API Convenience Routes 95 108 96 109 The main app exposes API routes that wrap PDS record operations. All routes require the signed `did` cookie. ··· 101 114 102 115 ### `POST /api/webhook` 103 116 104 - Creates a new webhook record. Body matches the `place.wisp.v2.wh` record shape. 117 + Creates a new webhook record. Body: 118 + 119 + ```json 120 + { 121 + "scopeAturi": "at://did:plc:abc123/app.bsky.feed.post", 122 + "url": "https://example.com/webhook", 123 + "backlinks": false, 124 + "events": ["create"], 125 + "secretId": "my-secret", 126 + "enabled": true 127 + } 128 + ``` 105 129 106 130 ### `DELETE /api/webhook/:rkey` 107 131 ··· 111 135 112 136 Returns the last 100 delivery events for the authenticated user. 113 137 138 + ## Signing Secrets API 139 + 140 + 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: 141 + 142 + ### `GET /api/secret` 143 + 144 + Lists all secrets (names and metadata only — tokens are never returned after creation). 145 + 146 + ### `POST /api/secret` 147 + 148 + Creates a new secret. Body: `{ "name": "my-secret" }`. 149 + 150 + Response includes `token` — **copy it now**, it will not be shown again. 151 + 114 152 ```json 115 - [ 116 - { 117 - "id": "...", 118 - "rkey": "abc123", 119 - "url": "https://example.com/webhook", 120 - "event_kind": "create", 121 - "event_did": "did:plc:...", 122 - "event_collection": "app.bsky.feed.post", 123 - "event_rkey": "3kl2jd9s8f7g", 124 - "status": "ok", 125 - "delivered_at": "2024-01-15T10:30:00.000Z" 126 - } 127 - ] 153 + { "success": true, "name": "my-secret", "token": "wsk_...", "createdAt": "..." } 128 154 ``` 155 + 156 + ### `POST /api/secret/:name/rotate` 157 + 158 + Generates a new token for an existing secret. The old token stops working immediately. Returns the new `token` once. 159 + 160 + ### `DELETE /api/secret/:name` 161 + 162 + Deletes a secret. Any webhooks referencing this `secretId` will stop being signed. 163 + 164 + These routes are also available as XRPC procedures under `place.wisp.v2.secret.*` for programmatic access with a service JWT. 129 165 130 166 ## Self-Hosting 131 167
+88
docs/src/content/docs/reference/xrpc-api.md
··· 237 237 ``` 238 238 239 239 **Errors:** `AuthenticationRequired`, `InvalidRequest`, `NotFound` 240 + 241 + --- 242 + 243 + ## Signing Secrets 244 + 245 + 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. 246 + 247 + All four endpoints require authentication (`AuthenticationRequired` on failure). 248 + 249 + ### `place.wisp.v2.secret.create` — procedure 🔒 250 + 251 + Creates a new signing secret scoped to the authenticated DID. 252 + 253 + **Input:** 254 + 255 + | Field | Type | Required | Notes | 256 + |---|---|---|---| 257 + | `name` | `string` (record-key) | ✅ | Unique per DID, `a-z0-9-` | 258 + 259 + **Response:** 260 + 261 + | Field | Type | Notes | 262 + |---|---|---| 263 + | `name` | `string` | | 264 + | `token` | `string` | `wsk_` prefixed — store this now, never shown again | 265 + | `createdAt` | `string` (datetime) | | 266 + 267 + **Errors:** `AuthenticationRequired`, `InvalidRequest`, `AlreadyExists` 268 + 269 + --- 270 + 271 + ### `place.wisp.v2.secret.list` — query 🔒 272 + 273 + Lists all secrets for the authenticated DID. Token values are never returned. 274 + 275 + **Response:** 276 + 277 + ```json 278 + { 279 + "secrets": [ 280 + { 281 + "name": "my-secret", 282 + "createdAt": "2024-01-15T10:30:00.000Z", 283 + "lastRotatedAt": "2024-02-01T09:00:00.000Z" 284 + } 285 + ] 286 + } 287 + ``` 288 + 289 + **Errors:** `AuthenticationRequired` 290 + 291 + --- 292 + 293 + ### `place.wisp.v2.secret.rotate` — procedure 🔒 294 + 295 + Generates a new token for an existing secret. The old token is invalidated immediately. 296 + 297 + **Input:** 298 + 299 + | Field | Type | Required | 300 + |---|---|---| 301 + | `name` | `string` | ✅ | 302 + 303 + **Response:** 304 + 305 + | Field | Type | Notes | 306 + |---|---|---| 307 + | `name` | `string` | | 308 + | `token` | `string` | New token — store this now, never shown again | 309 + | `rotatedAt` | `string` (datetime) | | 310 + 311 + **Errors:** `AuthenticationRequired`, `NotFound` 312 + 313 + --- 314 + 315 + ### `place.wisp.v2.secret.delete` — procedure 🔒 316 + 317 + Deletes a signing secret. Any webhooks referencing this `secretId` will stop being signed. 318 + 319 + **Input:** 320 + 321 + | Field | Type | Required | 322 + |---|---|---| 323 + | `name` | `string` | ✅ | 324 + 325 + **Response:** `{}` 326 + 327 + **Errors:** `AuthenticationRequired`, `NotFound`