Skip to content

Commit ea0d7ed

Browse files
yanxue06cursoragent
andcommitted
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>
1 parent b404239 commit ea0d7ed

4 files changed

Lines changed: 42 additions & 31 deletions

File tree

docs-src/webhooks/events.mdx.vel

Lines changed: 31 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ type WebhookEventPayload =
8585

8686
### `event: "messages"` payload
8787

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. Reactions and 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`.
8989

9090
| Field | Type | Description |
9191
| --- | --- | --- |
@@ -116,7 +116,7 @@ Every `space` object guarantees `id` and `platform`. Every other own enumerable
116116
**Forward-compatible.** New platform schema fields are forwarded automatically without a webhook deploy. Read the fields you care about; ignore the rest.
117117
</Note>
118118

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(...)`.
120120

121121
#### Message
122122

@@ -190,45 +190,52 @@ Every arm of the `Content` union, post-projection. Hand-written rather than vell
190190
worker isn't a vellum source package, and the wire shapes diverge from the
191191
SDK's Content type). Source of truth for the projection: spectrum-webhook
192192
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.
197198
#}
198199

200+
These are the arms a current provider (iMessage, WhatsApp Business) actually emits inbound today:
201+
199202
| `type` | What it is | Wire fields (post-projection) |
200203
| --- | --- | --- |
201204
| `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` |
203-
| `voice` | A voice memo — duration-aware audio attachment. | `mimeType: string`, `name?: string`, `duration?: number`, `size?: number` |
205+
| `attachment` | A file attachment — photo, video, audio, voice memo, or document. | `name: string`, `mimeType: string`, `size?: number` |
204206
| `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` |
205207
| `richlink` | A link preview — URL only on the wire; OG metadata is not pre-fetched. | `url: string` |
206208
| `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 }[] }` |
209210
| `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"` |
212211
| `custom` | A provider-specific event payload — `raw` is the entire payload by design. | `raw: unknown` (JSON-normalized: `Date` → ISO, functions stripped, cycles guarded) |
213212

214213
A few cross-cutting points the table doesn't surface:
215214

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.
218217
- **`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.
219218

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
221220

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 |
223224
| --- | --- | --- |
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). |
226233

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.
228235

229236
##### Target refs
230237

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:
232239

233240
| Field | Type | Description |
234241
| --- | --- | --- |
@@ -238,7 +245,7 @@ The `target` field on `reaction`, `reply`, and `edit` doesn't recurse into the f
238245
| `sender?` | `{ id, platform }` | The user who sent the referenced message, when known. |
239246
| `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`. |
240247

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.
242249

243250
<Tip>
244251
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.
@@ -256,8 +263,8 @@ switch (content.type) {
256263
case 'reaction':
257264
handleReaction(content.emoji, content.target.id);
258265
break;
259-
case 'reply':
260-
handleReply(content.content, content.target);
266+
case 'poll_option':
267+
handlePollVote(content.title, content.selected);
261268
break;
262269
default:
263270
console.warn('unknown content type:', content.type);
@@ -270,7 +277,7 @@ switch (content.type) {
270277

271278
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:
272279

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.
274281
- **Internal provider state.** Things like raw protocol headers, retry hints, and message acknowledgements are stripped before serialization.
275282
- **Outbound messages.** Webhooks deliver inbound only. A message you sent does not echo back as a webhook.
276283

webhooks/delivery.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ If your handler depends on order, sort by `message.timestamp` (which is the plat
194194
## What we *don't* deliver
195195

196196
- **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.
198198
- **Acknowledgements that you processed correctly.** Returning `2xx` only tells us the delivery succeeded; we don't track downstream state.
199199

200200
## When to use the SDK loop instead

webhooks/overview.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ Four ideas cover everything else in these docs:
118118

119119
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.
120120

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+
Reactions and 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.
122122

123123
## Security in one paragraph
124124

webhooks/quickstart.mdx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -171,10 +171,14 @@ If you already have a deployed HTTPS URL, skip the ngrok step.
171171
projectSecret: process.env.PROJECT_SECRET!,
172172
providers: [imessage.config()],
173173
});
174-
175-
// call this from your webhook handler (e.g. via a queue / RPC)
176-
export const reply = async (spaceId: string, body: string) => {
177-
const space = await app.spaces.get(spaceId);
174+
const im = imessage(app);
175+
176+
// call this from your webhook handler (e.g. via a queue / RPC).
177+
// There is no "get space by id" API — rebuild the DM from the sender's
178+
// address (the webhook's `message.sender.id`, an E.164 number for iMessage).
179+
export const reply = async (senderId: string, body: string) => {
180+
const user = await im.user(senderId);
181+
const space = await im.space(user);
178182
await space.send(text(body));
179183
};
180184
```
@@ -186,7 +190,7 @@ If you already have a deployed HTTPS URL, skip the ngrok step.
186190
if (!verify(c)) return c.text('bad signature', 401);
187191
const payload = JSON.parse(await c.req.text());
188192
if (payload.event === 'messages' && payload.message.content.type === 'text') {
189-
void enqueueReply(payload.space.id, `echo: ${payload.message.content.text}`);
193+
void enqueueReply(payload.message.sender.id, `echo: ${payload.message.content.text}`);
190194
}
191195
return c.text('ok', 200);
192196
});

0 commit comments

Comments
 (0)