Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions docs-src/webhooks/events.mdx.vel
Original file line number Diff line number Diff line change
Expand Up @@ -168,22 +168,22 @@ type Content =

**Text** is what you'll see for the vast majority of inbound messages.

**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:
**Attachment** is what you'll see for non-text file content from iMessage β€” photos, audio files, videos, documents. The `mimeType` field is the discriminator for *what kind* of attachment it is:

| `mimeType` prefix | Kind | Example values |
| --- | --- | --- |
| `image/*` | Photo or image attachment | `image/heic`, `image/jpeg`, `image/png` |
| `audio/*` | Voice memo or audio file | `audio/mp4`, `audio/x-m4a` |
| `audio/*` | Audio file | `audio/mp4`, `audio/x-m4a` |
| `video/*` | Video clip | `video/mp4`, `video/quicktime` |
| `application/*` | Document or file | `application/pdf`, `application/zip` |

<Warning>
**Byte-bearing arms ship metadata, not bytes.** `attachment` and `contact.photo` both carry `mimeType` / `size` / filename β€” never the raw bytes themselves and never a download URL. The `attachment` arm carries an `id` you use to fetch the file out of band β€” see [Retrieving an attachment](#retrieving-an-attachment).
**Byte-bearing arms ship metadata, not bytes.** `attachment`, `voice`, and `contact.photo` all carry `mimeType` / `size` / filename β€” never the raw bytes themselves and never a download URL. The `attachment` and `voice` arms carry an `id` you use to fetch the file out of band β€” see [Retrieving an attachment](#retrieving-an-attachment).
</Warning>

##### Retrieving an attachment

The payload tells you a file *exists* and what it is β€” it never carries the bytes, and there's no HTTP endpoint to download them (the same constraint as [sending](#what-you-dont-get)). The `attachment` arm's `id` is a stable, provider-native identifier β€” an iMessage GUID here; a Slack file id or WhatsApp media id on those platforms. To pull the bytes, run a [`spectrum-ts`](/spectrum-ts/getting-started) instance β€” the same one you'd use to reply β€” and hand the `id` to the provider's `getAttachment`:
The payload tells you a file *exists* and what it is β€” it never carries the bytes, and there's no HTTP endpoint to download them (the same constraint as [sending](#what-you-dont-get)). The `attachment` and `voice` arms' `id` is a stable, provider-native identifier β€” an iMessage GUID here; a Slack file id or WhatsApp media id on those platforms. To pull the bytes, run a [`spectrum-ts`](/spectrum-ts/getting-started) instance β€” the same one you'd use to reply β€” and hand the `id` to the provider's `getAttachment`:

```ts
// `im` is the same iMessage instance you reply through: const im = imessage(app)
Expand All @@ -206,15 +206,16 @@ The arms a current provider (iMessage, WhatsApp Business) delivers inbound today
| `type` | What it is | Wire fields (post-projection) |
| --- | --- | --- |
| `text` | A plain text message β€” the most common arm. | `text: string` |
| `attachment` | A file attachment β€” photo, video, audio, voice memo, or document. | `id: string`, `name: string`, `mimeType: string`, `size?: number` |
| `attachment` | A file attachment β€” photo, video, audio, or document. | `id: string`, `name: string`, `mimeType: string`, `size?: number` |
| `voice` | A voice message β€” native Apple voice memos on iMessage. | `id?: string`, `name?: string`, `mimeType: string`, `duration?: number`, `size?: number` |
| `contact` | A contact card β€” all fields optional. Full shape: [`Contact`](/spectrum-ts/content). | `name?: { formatted?, first?, last? }`, `phones?: [{ value, type? }]`, `photo?: { mimeType }`, `raw?` |
| `richlink` | A link preview β€” URL only on the wire; OG metadata is not pre-fetched. | `url: string` |
| `reaction` | An emoji reaction targeting another message. | `emoji: string`, [`target: MessageRef`](#target-refs) |
| `group` | An **album** β€” multiple attachments (e.g. several photos) sent as one message. | [`items`](#message)`: SerializedInboundMessage[]` β€” each entry is a full inbound message with its own `content` (typically `attachment`), `id`, `sender`, `timestamp`. Albums don't nest. See example below. |

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

- **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.
- **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.
- **`target` on `reaction`** is the slim shape documented under [Target refs](#target-refs) below, not a full nested message.
- **`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.

Expand Down Expand Up @@ -248,7 +249,7 @@ Each item has the same shape as the top-level [`message`](#message) and carries

##### Arms you won't receive inbound

Two arms you might expect arrive as something else: inbound **replies come as plain `text`** (there's no `reply` arm on the wire β€” the thread link isn't surfaced), and **voice memos come as `attachment`** (`audio/*`). Anything else in the SDK's [`Content`](/spectrum-ts/content) union is send-only or a rare provider-specific case β€” don't build on receiving it inbound.
One arm you might expect arrives as something else: inbound **replies come as plain `text`** (there's no `reply` arm on the wire β€” the thread link isn't surfaced). Anything else in the SDK's [`Content`](/spectrum-ts/content) union is send-only or a rare provider-specific case β€” don't build on receiving it inbound.

Don't branch on arms you can't receive β€” but always keep a `default:` case, so an arm a future SDK/provider bump starts delivering (forwarded generically as `type: string` plus its JSON-safe fields) can't break your handler.

Expand Down Expand Up @@ -297,6 +298,9 @@ switch (content.type) {
else if (content.mimeType.startsWith('audio/')) handleAudio(content);
else handleGenericAttachment(content);
break;
case 'voice':
handleVoice(content);
break;
case 'reaction':
handleReaction(content.emoji, content.target.id);
break;
Expand Down
34 changes: 22 additions & 12 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading