You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
docs(webhooks): only document content arms providers actually emit inbound
The events spec claimed reactions, typing, edits, and replies all arrive
inbound as `content.type` arms. Verified against the spectrum-ts iMessage and
WhatsApp Business inbound parsers: only `text`, `attachment`, `contact`,
`richlink`, `reaction`, `poll_option`, `group`, and `custom` are emitted
inbound today. `reply`/`edit`/`voice`/`effect`/`typing` are send-only or
not produced inbound (iMessage replies arrive as plain `text`; voice memos
as `audio/*` attachments), and a standalone `poll` arm is never emitted —
poll context rides inline on `poll_option`. Split the per-arm reference into
"delivered today" vs "tolerated but not emitted inbound" so the page stops
promising arms that never arrive.
Also fix a broken API reference in the quickstart reply example: the Spectrum
instance has no `app.spaces.get(spaceId)` (and there is no get-space-by-id
call). Rebuild the DM from the sender via `imessage(app).space(await
im.user(sender.id))`, and reflect the same in the events "what you don't get"
notes.
Co-authored-by: Cursor <cursoragent@cursor.com>
Copy file name to clipboardExpand all lines: docs-src/webhooks/events.mdx.vel
+31-24Lines changed: 31 additions & 24 deletions
Original file line number
Diff line number
Diff line change
@@ -85,7 +85,7 @@ type WebhookEventPayload =
85
85
86
86
### `event: "messages"` payload
87
87
88
-
This is the only event currently emitted. It fires once per inbound message that lands for your project. Reactions, typing, edits, and replies are **not** separate events — they arrive inside this `messages` payload, distinguished by `message.content.type` (see [Content shapes](#content-shapes)). Branch on `content.type`, not `event`.
88
+
This is the only event currently emitted. It fires once per inbound message that lands for your project. Reactionsand poll votes are **not** separate events — they arrive inside this `messages` payload, distinguished by `message.content.type` (see [Content shapes](#content-shapes)). Branch on `content.type`, not `event`.
89
89
90
90
| Field | Type | Description |
91
91
| --- | --- | --- |
@@ -116,7 +116,7 @@ Every `space` object guarantees `id` and `platform`. Every other own enumerable
116
116
**Forward-compatible.** New platform schema fields are forwarded automatically without a webhook deploy. Read the fields you care about; ignore the rest.
117
117
</Note>
118
118
119
-
The `space.id` matches the `space.id` you'd see from the [`spectrum-ts` SDK](/spectrum-ts/spaces-and-users) — pass it to `Space.send(...)` from a separately-running SDK instance to reply. There is no public HTTP send-message endpoint today.
119
+
The `space.id` matches the `space.id` you'd see from the [`spectrum-ts` SDK](/spectrum-ts/spaces-and-users). There is no public HTTP send-message endpoint, and no "get space by id" call — to reply, run a separate SDK instance and either use the live `Space` yielded by its `messages` stream or rebuild the conversation from the sender (`imessage(app).space(await im.user(sender.id))`), then call `space.send(...)`.
120
120
121
121
#### Message
122
122
@@ -190,45 +190,52 @@ Every arm of the `Content` union, post-projection. Hand-written rather than vell
190
190
worker isn't a vellum source package, and the wire shapes diverge from the
191
191
SDK's Content type). Source of truth for the projection: spectrum-webhook
192
192
src/delivery/serialize.ts (the SerializedXxx interfaces).
193
-
Document only the PRODUCT-SUPPORTED arms here. The dispatcher's
194
-
KNOWN_CONTENT_TYPES also defensively tolerates arms Spectrum does not emit
195
-
today (e.g. poll / poll_option / avatar) so a future SDK bump can't crash it
196
-
— those are intentionally NOT listed here. Re-check on any SDK bump.
193
+
The first table lists arms a CURRENT provider actually emits inbound
194
+
(verified against spectrum-ts iMessage + WhatsApp Business inbound parsers).
195
+
The second table lists arms the worker serializes defensively but that no
196
+
current provider delivers inbound today (send-only Content arms + forward-
197
+
compat). Re-check both on any SDK / provider bump.
197
198
#}
198
199
200
+
These are the arms a current provider (iMessage, WhatsApp Business) actually emits inbound today:
201
+
199
202
| `type` | What it is | Wire fields (post-projection) |
200
203
| --- | --- | --- |
201
204
| `text` | A plain text message — the most common arm. | `text: string` |
202
-
| `attachment` | A file attachment — photo, video, audio, or document. | `name: string`, `mimeType: string`, `size?: number` |
| `contact` | A vCard-style contact card with the provider-native blob in `raw`. | `user?: { id, platform }`, `name?`, `phones?`, `emails?`, `addresses?`, `org?`, `urls?`, `birthday?`, `note?`, `photo?: { mimeType }`, `raw?: unknown` |
205
207
| `richlink` | A link preview — URL only on the wire; OG metadata is not pre-fetched. | `url: string` |
206
208
| `reaction` | An emoji reaction targeting another message. | `emoji: string`, [`target: MessageRef`](#target-refs) |
207
-
| `reply` | A reply that quotes another message. | [`content: Content`](#content-shapes) (recursed), [`target: MessageRef`](#target-refs) |
208
-
| `edit` | An edit replacing an earlier message's content. | [`content: Content`](#content-shapes) (recursed), [`target: MessageRef`](#target-refs) |
209
+
| `poll_option` | A vote on a poll option (cast or retracted). Carries the full poll inline. | `title: string`, `selected: boolean`, `option: { title: string }`, `poll: { title, options: { title }[] }` |
209
210
| `group` | An album or batch — multiple messages bundled by the provider. | [`items: SerializedInboundMessage[]`](#message) (groups don't nest) |
210
-
| `effect` | An iMessage-style effect wrapping inner content. | `effect: string`, `content: text \| attachment` |
211
-
| `typing` | A typing-indicator state change. | `state: "start" \| "stop"` |
212
211
| `custom` | A provider-specific event payload — `raw` is the entire payload by design. | `raw: unknown` (JSON-normalized: `Date` → ISO, functions stripped, cycles guarded) |
213
212
214
213
A few cross-cutting points the table doesn't surface:
215
214
216
-
- **Byte-bearing arms** (`attachment`, `voice`, `contact.photo`) ship metadata only — see the [warning above](#content-shapes). The SDK's `read()` / `stream()` thunks and the internal filesystem `path` are dropped before delivery.
217
-
- **`target` on `reaction` / `reply` / `edit`** is the slim shape documented under [Target refs](#target-refs) below, not a full nested message.
215
+
- **Byte-bearing arms** (`attachment`, `contact.photo`) ship metadata only — see the [warning above](#content-shapes). The SDK's `read()` / `stream()` thunks and the internal filesystem `path` are dropped before delivery. iMessage voice memos arrive as `attachment` with an `audio/*` `mimeType`, not as a distinct arm.
216
+
- **`target` on `reaction`** is the slim shape documented under [Target refs](#target-refs) below, not a full nested message.
218
217
- **`richlink`** ships only `url` — OG fetches happen in your handler, since resolving them inline at delivery time would tie latency to the slowest target site.
219
218
220
-
**Forward-compat arms** — handled defensively until the SDK pin lands a hand-shaped projection. The dispatcher falls through generically rather than failing, so no webhook deploy is required when one of these later promotes to a published arm.
219
+
##### Arms the worker tolerates but no provider emits inbound today
221
220
222
-
| `type` | What it is | Wire fields (post-projection) |
221
+
The `Content` union also includes arms that are **send-only** (you produce them via the SDK's `space.send(...)`) or reserved for future providers. The worker has hand-shaped serializers for them, so they're forwarded correctly **if** a provider ever emits one inbound — but iMessage and WhatsApp Business do **not** deliver them inbound today. Don't write handler logic that depends on receiving them; do keep a `default:` arm so you tolerate them (and any genuinely new arm) gracefully.
222
+
223
+
| `type` | What it is | Status |
223
224
| --- | --- | --- |
224
-
| `rename` | A chat title change. | `displayName: string` |
225
-
| (any new arm) | A new arm the SDK ships before the worker has a hand-shaped projection. | `type: string`, plus every JSON-safe own field of the source arm (functions stripped, `Date` → ISO, cycles guarded). |
225
+
| `reply` | A threaded reply quoting another message. | Send-only today. Inbound iMessage replies arrive as plain `text` — the thread link is not surfaced on the wire. |
226
+
| `edit` | An edit replacing an earlier message's content. | Send-only today; not delivered inbound. |
227
+
| `poll` | A standalone "poll created" arm. | Not emitted on its own — poll context is delivered inline on `poll_option` instead. |
228
+
| `voice` | A duration-aware voice memo arm. | Not emitted — voice memos arrive as `attachment` (`audio/*`). |
229
+
| `effect` | An iMessage-style effect wrapping inner content. | Not emitted inbound. |
230
+
| `typing` | A typing-indicator state change. | Not delivered inbound in any form today. |
231
+
| `rename` | A chat title change. | Forward-compat; not emitted today. |
232
+
| (any new arm) | A new arm the SDK ships before the worker has a hand-shaped projection. | Forwarded generically: `type: string`, plus every JSON-safe own field (functions stripped, `Date` → ISO, cycles guarded). |
226
233
227
-
Always check `type` against the published set above before pattern-matching — a `default:` case in your switch that logs the unknown type and moves on is enough.
234
+
Always check `type` against the delivered set above before pattern-matching — a `default:` case in your switch that logs the unknown type and moves on is enough.
228
235
229
236
##### Target refs
230
237
231
-
The `target` field on `reaction`, `reply`, and `edit` doesn't recurse into the full referenced message. A reaction chain on a multi-KB photo would otherwise balloon a 200-byte ack into the entire ancestor tree. The wire ships a slim reference instead:
238
+
The `target` field on `reaction` — and on `reply` / `edit` if a future provider ever emits them inbound — doesn't recurse into the full referenced message. A reaction on a multi-KB photo would otherwise balloon a 200-byte ack into the entire ancestor tree. The wire ships a slim reference instead:
232
239
233
240
| Field | Type | Description |
234
241
| --- | --- | --- |
@@ -238,7 +245,7 @@ The `target` field on `reaction`, `reply`, and `edit` doesn't recurse into the f
238
245
| `sender?` | `{ id, platform }` | The user who sent the referenced message, when known. |
239
246
| `contentPreview?` | `string` | First 80 characters of the referenced message's text, with an ellipsis if truncated. Populated only when the target's content is `text`. |
240
247
241
-
Look the target up in your own message store (keyed off `id`) when you need its full body; `contentPreview` is enough for "in reply to «hello»" UI strings without a second round-trip.
248
+
Look the target up in your own message store (keyed off `id`) when you need its full body; `contentPreview` is enough for "👍 on «hello»" UI strings without a second round-trip.
242
249
243
250
<Tip>
244
251
Always handle unknown `content.type` values gracefully — new content arms may be added without a breaking version bump, and the worker forwards them through the generic projection above. A `default:` arm in your switch that logs and moves on is enough.
A few things that may be in the SDK's <TypeTooltip name="Message" type={`{{ message.signature }}`} /> type but are intentionally **not** in the webhook payload:
272
279
273
-
- **Methods like `.reply()` or `.react()`.** They depend on a live SDK connection. To respond, run [`spectrum-ts`](/spectrum-ts/getting-started) in a separate process and call `space.send(...)` against the `space.id` you got from the webhook. There is no HTTP send endpoint yet.
280
+
- **Methods like `.reply()` or `.react()`.** They depend on a live SDK connection. To respond, run [`spectrum-ts`](/spectrum-ts/getting-started) in a separate process and reply through a live `Space` (from its `messages` stream, or rebuilt from the sender). There is no HTTP send endpoint, and no "get space by id" call, yet.
274
281
- **Internal provider state.** Things like raw protocol headers, retry hints, and message acknowledgements are stripped before serialization.
275
282
- **Outbound messages.** Webhooks deliver inbound only. A message you sent does not echo back as a webhook.
Copy file name to clipboardExpand all lines: webhooks/delivery.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -194,7 +194,7 @@ If your handler depends on order, sort by `message.timestamp` (which is the plat
194
194
## What we *don't* deliver
195
195
196
196
-**Outbound messages.** A message you send via the API does not echo back as a webhook.
197
-
-**Standalone reaction / typing / edit events.**These arrive *today* as `content.type`arms inside the `messages` payload, not as separate top-level events — branch on `content.type`. Read receipts aren't delivered in any form yet.
197
+
-**Standalone reaction events.**Reactions arrive *today* as a `content.type`arm inside the `messages` payload, not as a separate top-level event — branch on `content.type`. Typing indicators, edits, and read receipts aren't delivered in any form yet.
198
198
-**Acknowledgements that you processed correctly.** Returning `2xx` only tells us the delivery succeeded; we don't track downstream state.
Copy file name to clipboardExpand all lines: webhooks/overview.mdx
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -118,7 +118,7 @@ Four ideas cover everything else in these docs:
118
118
119
119
Today there is one event: **`messages`**. Each delivery carries `X-Spectrum-Event: messages` and a body of shape `{ event, space, message }` — see [Events](/webhooks/events) for every field.
120
120
121
-
Reactions, typing, edits, and replies aren't separate events — they already arrive today *inside* a `messages` payload, distinguished by `message.content.type` (you branch on `content.type`, not `event`). The set of top-level events may still grow later; new event types are additive, so handlers that ignore unknown values keep working without changes.
121
+
Reactionsand poll votes aren't separate events — they already arrive today *inside* a `messages` payload, distinguished by `message.content.type` (you branch on `content.type`, not `event`). The set of top-level events may still grow later; new event types are additive, so handlers that ignore unknown values keep working without changes.
0 commit comments