Skip to main content
In the Quickstart, a real delivery flew past in console.log. This page is the spec — every header and every field your handler will see on every request, slowed down and labelled. The body’s space, message, sender, and content fields are the same shapes you’d see from the spectrum-ts SDK — with one important difference: function-typed properties (.read(), .stream(), .reply(), .react(), etc.) are stripped before serialization, since functions can’t survive JSON.stringify. Every other own enumerable field declared by the platform’s schema (e.g. iMessage’s phone and type) is forwarded unchanged.

Anatomy of a delivery

The exact ID formats and the platform value are decided by each provider — not by Spectrum. The example above is an iMessage delivery; WhatsApp Business and other platforms use their own conventions. Don’t pattern-match on these strings; use them as opaque identifiers.

Headers

HTTP headers are case-insensitive. Most frameworks normalize to lowercase (x-spectrum-event); use whichever your framework returns.

Body shape

The body is a JSON object. The event field is a discriminator — every other field’s shape depends on which event you’re handling.

event: "messages" payload

This is the only event currently emitted. It fires once per inbound message that lands for your project. A reaction is not a separate event — it arrives inside this messages payload as a message.content.type arm (see Content shapes). Branch on content.type, not event.

Space

Every space object guarantees id and platform. Every other own enumerable field declared by the platform’s Space schema is forwarded inline — function-typed SDK methods (send, edit, getMessage, etc.) are the only thing stripped. The current per-platform fields are:
Forward-compatible. New platform schema fields are forwarded automatically without a webhook deploy. Read the fields you care about; ignore the rest.
The space.id matches the space.id you’d see from the spectrum-ts SDK. 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(...).

Message

Idempotency: the message.id rule

A single inbound message always carries the same message.id across every delivery it produces, no matter how many webhook URLs you have registered. If you have two URLs registered for one project and a message arrives, both URLs receive a POST in parallel — and both bodies have the same message.id. If a delivery is retried (after a 5xx or timeout on your side), the retry also carries the same message.id. That makes message.id the right dedup key when one downstream consumer handles every webhook for the project:
If different services consume different webhook URLs and each one needs its own dedup table, scope the key with the webhook id so the same message processed by service A doesn’t suppress service B:

Content shapes

content is a discriminated union tagged by type. The tooltip shows the SDK’s in-process type — it carries method thunks (read(), stream()) and full nested Message targets. The wire is a projection of it: thunks and server-internal fields are dropped, and message targets are slimmed to a ref. The Content union covers everything you can send; only a subset is delivered inbound. The two you’ll see on the vast majority of deliveries are text and attachment:
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:
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
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). 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 instance — the same one you’d use to reply — and hand the id to the provider’s getAttachment:
getAttachment resolves to the attachment with lazy byte accessors — read() buffers the whole file, stream() opens a byte stream — or undefined if the provider no longer has it. Pass space.phone so a pooled (shared) line resolves against the right account; on a dedicated line it’s optional. Other providers expose their own retrieval keyed off the same id — see each provider’s guide.
Per-arm wire reference
The arms a current provider (iMessage, WhatsApp Business) delivers inbound today, with their post-projection wire fields: A few cross-cutting points the table doesn’t surface:
  • Byte-bearing arms (attachment, contact.photo) ship metadata only — see the warning above. 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.
  • target on reaction is the slim shape documented under 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.
A group (album) nests each attachment as a full inbound message in items — here, two photos sent together:
Each item has the same shape as the top-level message and carries its own content; for an album they’re attachments, and item ids are child ids (p:<n>/spc-msg-<uuid>).
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 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.
Target refs
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: 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. A received reaction is just a content arm on a normal messages delivery — here, a ❤️ on the message from Anatomy of a delivery:
On a reaction delivery, message.sender is who reacted; content.target.sender is who sent the message they reacted to.
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.

What you don’t get

A few things that may be in the SDK’s type but are intentionally not in the webhook payload:
  • Methods like .reply() or .react(). They depend on a live SDK connection. To respond, run spectrum-ts 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.
  • Internal provider state. Things like raw protocol headers, retry hints, and message acknowledgements are stripped before serialization.
  • Outbound messages. Webhooks deliver inbound only. A message you sent does not echo back as a webhook.

Forward compatibility

The set of events will grow. To stay forward-compatible:
  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.
  2. Treat extra fields as unknown but tolerable. New optional fields may appear in existing payload shapes. They’ll never repurpose existing fields.
  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.

Quick reference card

Where to next

You now know exactly what arrives at your URL. The next two chapters cover trusting it and handling failures when something goes wrong.

Verifying signatures

The four details every verifier has to get exactly right, with copy-paste implementations in four languages.

Delivery and retries

Retry policy, timeouts, idempotency, and what HTTP status codes mean to the worker.