Skip to content

Commit c3234c7

Browse files
authored
Merge pull request #26 from photon-hq/vellum-generated-events
docs(webhooks): convert events.mdx to Vellum template for type drift …
2 parents 88dcba3 + 4ba992b commit c3234c7

2 files changed

Lines changed: 67 additions & 15 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ advanced-kits/whatsapp/*.mdx
99
opensource/*.mdx
1010
spectrum-ts/*.mdx
1111
spectrum-ts/**/*.mdx
12+
webhooks/events.mdx
1213

Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,22 @@ title: Events
33
description: The exact wire format Spectrum sends — headers, body, and what each field contains
44
---
55

6+
import { TypeTooltip } from "/snippets/type-tooltip.mdx";
7+
8+
{% set space = symbol("ts:spectrum-ts#Space") %}
9+
{% set user = symbol("ts:spectrum-ts#User") %}
10+
11+
{# Note: We intentionally do NOT use TypeTooltip for `Content` or
12+
`InboundMessage` because:
13+
- `Content = z.infer<typeof contentSchema>` resolves to a Zod inference
14+
reference that's unhelpful to customers.
15+
- `InboundMessage` is generic and the extractor returns an empty signature.
16+
The Space and User types are plain interfaces that extract cleanly. #}
17+
618
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.
719

20+
The body's `space`, `message`, `sender`, and `content` fields are the same shapes you'd see from the [`spectrum-ts` SDK](/spectrum-ts/messages) — with one important difference: function-typed properties (`.read()`, `.stream()`, `.reply()`, `.react()`, etc.) are stripped before serialization, since functions can't survive `JSON.stringify`.
21+
822
## Anatomy of a delivery
923

1024
```http
@@ -13,9 +27,9 @@ Host: your-app.com
1327
Content-Type: application/json
1428
User-Agent: spectrum-webhook/0.1.0
1529
X-Spectrum-Event: messages
16-
X-Spectrum-Webhook-Id: 6a4d2e8c-7b1f-4d3a-9a8e-2c5d6f7e8a9b
30+
X-Spectrum-Webhook-Id: 60d6d04f-f9fa-4a7b-9c97-37c9c90ce91c
1731
X-Spectrum-Timestamp: 1747242392
18-
X-Spectrum-Signature: v0=3a1f7c8b2d9e0a4f6e3c5b8a1d2e4f6a8b0c3d5e7f9a1b2c4d6e8f0a2b4c6d8e
32+
X-Spectrum-Signature: v0=fc9bf49ef3ba4122ba4be6e289f88ac692f5ce8e13f0415cb38d59428eae8a8c
1933

2034
{
2135
"event": "messages",
@@ -44,6 +58,8 @@ X-Spectrum-Signature: v0=3a1f7c8b2d9e0a4f6e3c5b8a1d2e4f6a8b0c3d5e7f9a1b2c4d6e8f0
4458
}
4559
```
4660

61+
The exact ID formats and the `platform` value are decided by each provider — not by Spectrum. The example above is real prod output for an iMessage delivery; WhatsApp Business and any future platform will use their own conventions. Don't pattern-match on these strings; use them as opaque identifiers.
62+
4763
## Headers
4864

4965
| Header | Value | Notes |
@@ -76,8 +92,8 @@ This is the only event currently emitted. It fires once per inbound message that
7692
| Field | Type | Description |
7793
| --- | --- | --- |
7894
| `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). |
95+
| `space` | <TypeTooltip name="Space" type={`{{ space.signature }}`} /> | The conversation context. See [Space](#space) below. |
96+
| `message` | object | The inbound message. See [Message](#message) below. |
8197

8298
#### Space
8399

@@ -86,19 +102,19 @@ This is the only event currently emitted. It fires once per inbound message that
86102
| `id` | `string` | Opaque, stable identifier for the conversation. Format varies by platform and space type — treat it as a string you store and pass back unchanged. For iMessage DMs, looks like `any;-;+<E.164>`; for groups, a chat GUID. |
87103
| `platform` | `string` | The platform that owns this space. See [Providers](/spectrum-ts/providers) for the current set of values; new platforms add new values without breaking existing payloads. |
88104

89-
The `space.id` matches the `space.id` you'd see from the [`spectrum-ts` SDK](/spectrum-ts/spaces-and-users). To send a reply, pass it to `space.send(...)` from a separately-running SDK instance — there is no public HTTP send-message endpoint today.
105+
The `space.id` matches the `space.id` you'd see from the [`spectrum-ts` SDK](/spectrum-ts/spaces-and-users). To send a reply, pass it to <TypeTooltip name="Space" type={`{{ space.signature }}`} />`.send(...)` from a separately-running SDK instance — there is no public HTTP send-message endpoint today.
90106

91107
#### Message
92108

93109
| Field | Type | Description |
94110
| --- | --- | --- |
95-
| `id` | `string` | Stable, platform-prefixed message id. Use this for idempotency. |
96-
| `platform` | `string` | The platform that sourced the message. |
111+
| `id` | `string` | Stable opaque identifier for the message. The current format is `spc-msg-<uuid>` but treat it as opaque — use it for idempotency, don't parse it. |
112+
| `platform` | `string` | The platform that sourced the message. Same value as `space.platform`. |
97113
| `direction` | `"inbound"` | Always `"inbound"` — outbound messages are not delivered as webhooks. |
98114
| `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. |
115+
| `sender` | <TypeTooltip name="User" type={`{{ user.signature }}`} /> | The user who sent the message — `{ id, platform }`. The `id` format is platform-defined (e.g. for iMessage it's the E.164 phone number `+15551234567`; for WhatsApp Business it's the WA contact id). |
116+
| `space` | <TypeTooltip name="Space" type={`{{ space.signature }}`} /> | A copy of the top-level `space` field, denormalized for convenience. |
117+
| `content` | object | The message content. Shape depends on the message type — see [Content shapes](#content-shapes) below. |
102118

103119
#### Idempotency: the `message.id` rule
104120

@@ -121,20 +137,55 @@ const dedupeKey = `${webhookId}:${payload.message.id}`;
121137

122138
#### Content shapes
123139

124-
`content` is a discriminated union tagged by `type`. It mirrors the [`message.content` shape](/spectrum-ts/content) from the `spectrum-ts` SDK.
140+
`content` is a discriminated union tagged by `type`. The two shapes verified end-to-end against prod today are:
125141

126142
```ts
127143
type Content =
128144
| { type: 'text'; text: string }
129-
| { type: 'image'; url: string; mimeType: string }
130-
| { type: 'audio'; url: string; mimeType: string; durationSec?: number }
131-
// ... more types
145+
| {
146+
type: 'attachment';
147+
name: string; // original filename, e.g. "IMG_4127.HEIC"
148+
mimeType: string; // e.g. "image/heic", "audio/mp4", "application/pdf"
149+
size: number; // bytes
150+
};
151+
// future content types may be added; handle unknown `type` values defensively
132152
```
133153

134-
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.
154+
**Text** is what you'll see for the vast majority of inbound messages.
155+
156+
**Attachment** is what you'll see for any non-text content from iMessage — photos, voice memos, audio files, videos, documents. The `mimeType` field is the discriminator for *what kind* of attachment it is:
157+
158+
| `mimeType` prefix | Kind | Example values |
159+
| --- | --- | --- |
160+
| `image/*` | Photo or image attachment | `image/heic`, `image/jpeg`, `image/png` |
161+
| `audio/*` | Voice memo or audio file | `audio/mp4`, `audio/x-m4a` |
162+
| `video/*` | Video clip | `video/mp4`, `video/quicktime` |
163+
| `application/*` | Document or file | `application/pdf`, `application/zip` |
164+
165+
The SDK's `Content` type defines additional arms (`reaction`, `richlink`, `poll`, `contact`, etc.) that may appear in future webhook deliveries. See [Spectrum content types](/spectrum-ts/messages#narrowing-content) for the canonical list. If a new arm ships, your `default:` switch arm is what catches it gracefully — see the snippet below.
166+
167+
<Warning>
168+
**Attachment payloads are metadata only.** The wire format does not include the file bytes or a download URL — only the filename, MIME type, and size. To process the actual content you'll need an additional retrieval step. A first-class HTTP download endpoint is on the roadmap; until then, contact support if you need attachment retrieval.
169+
</Warning>
135170

136171
<Tip>
137172
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.
173+
174+
```ts
175+
switch (content.type) {
176+
case 'text':
177+
handleText(content.text);
178+
break;
179+
case 'attachment':
180+
if (content.mimeType.startsWith('image/')) handleImage(content);
181+
else if (content.mimeType.startsWith('audio/')) handleAudio(content);
182+
else handleGenericAttachment(content);
183+
break;
184+
default:
185+
console.warn('unknown content type:', content);
186+
break;
187+
}
188+
```
138189
</Tip>
139190

140191
## What you don't get

0 commit comments

Comments
 (0)