Skip to content

Commit 7cde0b9

Browse files
authored
feat: webhook docs (#24)
1 parent 3e6d454 commit 7cde0b9

8 files changed

Lines changed: 1402 additions & 0 deletions

File tree

docs.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,28 @@
142142
}
143143
]
144144
},
145+
{
146+
"tab": "Webhooks",
147+
"groups": [
148+
{
149+
"group": "Getting Started",
150+
"pages": [
151+
"webhooks/overview",
152+
"webhooks/quickstart",
153+
"webhooks/events"
154+
]
155+
},
156+
{
157+
"group": "Implementation",
158+
"pages": [
159+
"webhooks/verifying-signatures",
160+
"webhooks/delivery",
161+
"webhooks/managing-webhooks",
162+
"webhooks/troubleshooting"
163+
]
164+
}
165+
]
166+
},
145167
{
146168
"tab": "API reference",
147169
"groups": [

webhooks/delivery.mdx

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
---
2+
title: Delivery and retries
3+
description: How Spectrum decides when to retry, when to give up, and what your endpoint should return
4+
---
5+
6+
This page describes what happens *after* the worker computes a signature and starts the `POST` to your URL. The contract is simple but worth knowing exactly, because it determines how fault-tolerant you need to be on your end.
7+
8+
## The contract in one paragraph
9+
10+
Spectrum tries to deliver each event for up to four attempts within a ~6.2 second window. Each attempt has a 10-second per-request timeout. Retries happen on `5xx`, `408`, `429`, network errors, and our own timeouts; other `4xx` codes mean "give up." Successful deliveries acknowledge with any `2xx`. After the budget is exhausted the event is dropped from the worker's memory — there is no durable retry queue. Customers who care about every event must dedupe and tolerate occasional misses.
11+
12+
## What the worker does on each attempt
13+
14+
```mermaid
15+
sequenceDiagram
16+
participant W as Spectrum worker
17+
participant Y as Your endpoint
18+
19+
W->>Y: POST (signed) — attempt 1
20+
Y-->>W: 200 OK
21+
Note over W: ✓ delivered, stop
22+
```
23+
24+
If the first attempt fails, the worker waits and tries again:
25+
26+
```mermaid
27+
sequenceDiagram
28+
participant W as Spectrum worker
29+
participant Y as Your endpoint
30+
31+
W->>Y: POST — attempt 1
32+
Y-->>W: 503
33+
W->>W: wait 200ms
34+
W->>Y: POST — attempt 2
35+
Y-->>W: 503
36+
W->>W: wait 1s
37+
W->>Y: POST — attempt 3
38+
Y-->>W: 200 OK
39+
Note over W: ✓ delivered after retry
40+
```
41+
42+
Total wall-clock budget across all attempts: roughly 6.2 seconds (200ms + 1s + 5s of sleeps, plus per-attempt time on the network). The worker stops as soon as it gets a 2xx or determines further retries are pointless.
43+
44+
## Retry policy
45+
46+
| Attempt | Delay before this attempt |
47+
| --- | --- |
48+
| 1 | none — fires immediately |
49+
| 2 | 200ms after attempt 1 ends |
50+
| 3 | 1 second after attempt 2 ends |
51+
| 4 | 5 seconds after attempt 3 ends |
52+
53+
Per-attempt timeout: **10 seconds** (configurable via `DELIVERY_TIMEOUT_MS` on our side, but you should treat 10s as the practical ceiling).
54+
55+
After attempt 4 fails, the event is logged and dropped. There is no persistent queue and no dead-letter destination — both are out of scope for v1.
56+
57+
<Note>
58+
Multiple registered URLs receive the same event in parallel via `Promise.allSettled`. One slow or failing URL never delays delivery to the others.
59+
</Note>
60+
61+
## What your status codes mean to us
62+
63+
| Status code(s) | Worker treats as | Result |
64+
| --- | --- | --- |
65+
| `2xx` | Success | Delivery complete. Stop. |
66+
| `5xx` | Retriable | Wait, retry up to 3 more times. |
67+
| `408 Request Timeout` | Retriable | Wait, retry. |
68+
| `429 Too Many Requests` | Retriable | Wait, retry. We don't honor `Retry-After` yet — use any 5xx/429 to backpressure. |
69+
| Any other `4xx` (e.g. `400`, `401`, `403`, `404`, `422`) | Fatal | Don't retry. The assumption is that the request will never succeed (auth bug, schema mismatch, missing route). |
70+
| Connection refused / DNS error / TCP reset | Retriable | Wait, retry. |
71+
| Per-attempt timeout (>10s) | Retriable | Wait, retry. |
72+
73+
<Tip>
74+
**Return `4xx` deliberately.** Returning `400` or `401` from a real bug (e.g. signature verification failure) is correct — it tells us "stop retrying, this request will never work." Returning `500` for the same bug wastes our retry budget and your CPU cycles.
75+
</Tip>
76+
77+
## What you should do on your end
78+
79+
### Acknowledge fast, process asynchronously
80+
81+
Return `2xx` as soon as you've **verified the signature and queued the work**. Do not block the response on slow downstream operations (LLM calls, third-party APIs, large database writes).
82+
83+
```ts
84+
app.post('/spectrum-webhook', async (c) => {
85+
if (!verify(c)) return c.text('bad signature', 401);
86+
87+
const payload = JSON.parse(await c.req.text());
88+
void enqueueForProcessing(payload);
89+
90+
return c.text('ok', 200);
91+
});
92+
```
93+
94+
If your handler takes >10 seconds, the worker will time out the connection, mark it retriable, and `POST` again. Now you'll process the same event twice.
95+
96+
### Be idempotent
97+
98+
At-least-once delivery means the same event can arrive more than once if your server hung after processing but before responding. Dedupe in your handler:
99+
100+
```ts
101+
const dedupeKey = `${webhookId}:${payload.message.id}`;
102+
103+
if (await alreadyProcessed(dedupeKey)) {
104+
return c.text('ok', 200);
105+
}
106+
107+
await processOnce(payload);
108+
await markProcessed(dedupeKey);
109+
```
110+
111+
A short TTL (24-48 hours) on the dedupe table is enough — by then the worker has long since moved on.
112+
113+
### Handle bursts
114+
115+
A noisy chat (group thread, mass DM) can produce many events per second. Make sure your handler can either:
116+
117+
- Process events at the rate they arrive, or
118+
- Queue them durably (BullMQ, SQS, Postgres-backed queue, anything) and return `2xx` immediately.
119+
120+
Returning `503` on overload is fine — we'll back off and retry. But it eats into your retry budget; queueing is preferable.
121+
122+
## Failure modes and what they cost you
123+
124+
| Scenario | Outcome |
125+
| --- | --- |
126+
| Endpoint returns `2xx` on first try | Best case. One delivery, one process. |
127+
| Endpoint returns `503`, recovers within 6s | Retried, eventually delivered. One process (assuming no `2xx` on the failed attempt). |
128+
| Endpoint times out after 10s, then succeeds | Retried, eventually delivered. **Possibly processed twice** — your handler ran during the timeout and again on retry. Dedupe required. |
129+
| Endpoint returns `400` (signature bug, etc.) | Dropped immediately, no retry. Event lost. Logged on our side. |
130+
| Endpoint down for >6 seconds | Dropped after 4 attempts. Event lost. |
131+
| Spectrum worker crashes mid-delivery | Event lost — no durable queue. Subsequent events resume after restart. |
132+
133+
The "event lost" rows are why this is **at-least-once, with bounded retries**, not "guaranteed delivery." If your use case requires zero loss (financial transactions, audit logging), pair webhooks with periodic reconciliation against the [Spectrum API](/api-reference/introduction) — list messages on the space and backfill anything you missed.
134+
135+
## Order and parallelism
136+
137+
- **No global ordering guarantee.** Events from different projects, different spaces, or different platforms can arrive in any order.
138+
- **No per-space ordering guarantee.** A late retry for an earlier message can land after a successfully-delivered later message.
139+
- **Parallel deliveries to multiple URLs.** If you have multiple webhooks registered, they receive each event in parallel and may finish in any order.
140+
141+
If your handler depends on order, sort by `message.timestamp` (which is the platform's send time, not the delivery time) and rely on dedupe to handle late arrivals.
142+
143+
## What we *don't* deliver
144+
145+
- **Outbound messages.** A message you send via the API does not echo back as a webhook.
146+
- **Read receipts, typing indicators, reactions.** Coming as separate event types in future versions — they are not in `messages` payloads today.
147+
- **Acknowledgements that you processed correctly.** Returning `2xx` only tells us the delivery succeeded; we don't track downstream state.
148+
149+
## When to use the SDK loop instead
150+
151+
If you find yourself working hard to compensate for delivery loss, consider running [`spectrum-ts`](/spectrum-ts/getting-started) directly instead of (or in addition to) webhooks. The SDK's `instance.messages` async iterable is a long-lived stream — slower events can't be lost to a delivery timeout because there is no delivery, just a `for await` loop running in your process.
152+
153+
A common pattern: webhooks for low-latency push, and a periodic reconciliation worker that uses the SDK or API to backfill anything the webhook layer missed.

webhooks/events.mdx

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
---
2+
title: Events
3+
description: The exact wire format Spectrum sends — headers, body, and what each field contains
4+
---
5+
6+
This page is the spec. Every webhook delivery is an HTTPS `POST` with a JSON body and four custom headers. The shape below is what your handler will see on every request.
7+
8+
## Anatomy of a delivery
9+
10+
```http
11+
POST /your-webhook-path HTTP/1.1
12+
Host: your-app.com
13+
Content-Type: application/json
14+
User-Agent: spectrum-webhook/0.1.0
15+
X-Spectrum-Event: messages
16+
X-Spectrum-Webhook-Id: 6a4d2e8c-7b1f-4d3a-9a8e-2c5d6f7e8a9b
17+
X-Spectrum-Timestamp: 1747242392
18+
X-Spectrum-Signature: v0=3a1f7c8b2d9e0a4f6e3c5b8a1d2e4f6a8b0c3d5e7f9a1b2c4d6e8f0a2b4c6d8e
19+
20+
{
21+
"event": "messages",
22+
"space": {
23+
"id": "imessage:chat:42",
24+
"platform": "imessage"
25+
},
26+
"message": {
27+
"id": "imessage:msg:abc123",
28+
"platform": "imessage",
29+
"direction": "inbound",
30+
"timestamp": "2026-05-14T19:06:32.000Z",
31+
"sender": {
32+
"id": "imessage:+15551234567",
33+
"platform": "imessage"
34+
},
35+
"space": {
36+
"id": "imessage:chat:42",
37+
"platform": "imessage"
38+
},
39+
"content": {
40+
"type": "text",
41+
"text": "hey, what time is dinner?"
42+
}
43+
}
44+
}
45+
```
46+
47+
## Headers
48+
49+
| Header | Value | Notes |
50+
| --- | --- | --- |
51+
| `Content-Type` | `application/json` | Always. The body is UTF-8 JSON. |
52+
| `User-Agent` | `spectrum-webhook/<version>` | Identifies the worker. Useful for IP/UA allow-listing. |
53+
| `X-Spectrum-Event` | Event type, e.g. `messages` | Mirrors the `event` field in the body. Lets you route without parsing the body first. |
54+
| `X-Spectrum-Webhook-Id` | UUID of the registered webhook | Identifies *which* of your URLs this delivery is for. Useful with multiple registrations and required for idempotency keys. |
55+
| `X-Spectrum-Timestamp` | UNIX epoch seconds at signing time | Required to verify the signature. Also reject deliveries older than ~5 minutes for replay protection. |
56+
| `X-Spectrum-Signature` | `v0=<64-char hex>` | HMAC-SHA256 of `v0:{timestamp}:{rawBody}` keyed by the webhook's signing secret. See [Verifying signatures](/webhooks/verifying-signatures). |
57+
58+
<Note>
59+
HTTP headers are case-insensitive. Most frameworks normalize to lowercase (`x-spectrum-event`); use whichever your framework returns.
60+
</Note>
61+
62+
## Body shape
63+
64+
The body is a JSON object. The `event` field is a discriminator — every other field's shape depends on which event you're handling.
65+
66+
```ts
67+
type WebhookEventPayload =
68+
| { event: 'messages'; space: SerializedSpace; message: SerializedInboundMessage };
69+
// future events extend this union
70+
```
71+
72+
### `event: "messages"` payload
73+
74+
This is the only event currently emitted. It fires once per inbound message that lands for your project.
75+
76+
| Field | Type | Description |
77+
| --- | --- | --- |
78+
| `event` | `"messages"` | Discriminator. Always `"messages"` for this payload. |
79+
| `space` | object | The conversation context. See [Space](#space). |
80+
| `message` | object | The inbound message. See [Message](#message). |
81+
82+
#### Space
83+
84+
| Field | Type | Description |
85+
| --- | --- | --- |
86+
| `id` | `string` | Stable, platform-prefixed identifier (e.g. `imessage:chat:42`). |
87+
| `platform` | `string` | The platform that owns this space (`imessage`, `whatsapp_business`). |
88+
89+
The `space.id` matches the `space.id` you'd see from the [`spectrum-ts` SDK](/spectrum-ts/spaces-and-users). You can use it to send a reply back via the [Spectrum API](/api-reference/introduction).
90+
91+
#### Message
92+
93+
| Field | Type | Description |
94+
| --- | --- | --- |
95+
| `id` | `string` | Stable, platform-prefixed message id. Use this for idempotency. |
96+
| `platform` | `string` | The platform that sourced the message. |
97+
| `direction` | `"inbound"` | Always `"inbound"` — outbound messages are not delivered as webhooks. |
98+
| `timestamp` | `string` | ISO 8601 UTC timestamp from the platform (when the user sent it). |
99+
| `sender` | object | The user who sent the message — `{ id, platform }`. |
100+
| `space` | object | A copy of the top-level `space` field, denormalized for convenience. |
101+
| `content` | object | The message content. Shape depends on the message type — see below. |
102+
103+
#### Content shapes
104+
105+
`content` is a discriminated union tagged by `type`. It mirrors the [`message.content` shape](/spectrum-ts/content) from the `spectrum-ts` SDK.
106+
107+
```ts
108+
type Content =
109+
| { type: 'text'; text: string }
110+
| { type: 'image'; url: string; mimeType: string }
111+
| { type: 'audio'; url: string; mimeType: string; durationSec?: number }
112+
// ... more types
113+
```
114+
115+
The complete reference (with every content type and its fields) lives in [Spectrum content types](/spectrum-ts/content). The same definitions apply on the wire.
116+
117+
<Tip>
118+
Always handle unknown `content.type` values gracefully — new content types may be added without a breaking version bump. A `default:` arm in your switch that logs and moves on is enough.
119+
</Tip>
120+
121+
## What you don't get
122+
123+
A few things that may be in the SDK's `Message` type but are intentionally **not** in the webhook payload:
124+
125+
- **Methods like `.reply()` or `.react()`.** They depend on a live SDK connection. To respond, call the [Spectrum API](/api-reference/introduction) using `space.id`.
126+
- **Internal provider state.** Things like raw protocol headers, retry hints, and message acknowledgements are stripped before serialization.
127+
- **Outbound messages.** Webhooks deliver inbound only. A message you sent does not echo back as a webhook.
128+
129+
## Forward compatibility
130+
131+
The set of events will grow. To stay forward-compatible:
132+
133+
1. **Branch on `event` defensively.** Use a `switch` with a `default` arm that returns `2xx` and logs the unknown event. Never crash on unknown values.
134+
135+
```ts
136+
switch (payload.event) {
137+
case 'messages':
138+
handleMessage(payload);
139+
break;
140+
default:
141+
console.warn('unknown webhook event', payload.event);
142+
break;
143+
}
144+
return new Response('ok', { status: 200 });
145+
```
146+
147+
2. **Treat extra fields as unknown but tolerable.** New optional fields may appear in existing payload shapes. They'll never repurpose existing fields.
148+
149+
3. **Don't subscribe to specific event types.** Today every URL receives every event. If a `subscriptions` field lands in the future, the default will continue to be "all events" so existing webhooks keep working.
150+
151+
## Quick reference card
152+
153+
```text
154+
Required to verify a delivery
155+
X-Spectrum-Timestamp + X-Spectrum-Signature + raw body bytes + your signingSecret
156+
157+
Required to route a delivery
158+
X-Spectrum-Event (or body.event)
159+
160+
Required for idempotency
161+
X-Spectrum-Webhook-Id + body.message.id (for messages event)
162+
163+
Always returns 2xx fast
164+
Process asynchronously after acknowledging — see /webhooks/delivery
165+
```

0 commit comments

Comments
 (0)