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
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
| Header | Value | Notes |
|---|---|---|
Content-Type | application/json | Always. The body is UTF-8 JSON. |
User-Agent | spectrum-webhook/<version> | Identifies the worker. Useful for IP/UA allow-listing. |
X-Spectrum-Event | Event type, e.g. messages | Mirrors the event field in the body. Lets you route without parsing the body first. |
X-Spectrum-Webhook-Id | UUID of the registered webhook | Identifies which of your URLs this delivery is for. Useful with multiple registrations and required for idempotency keys. |
X-Spectrum-Timestamp | UNIX epoch seconds at signing time | Required to verify the signature. Also reject deliveries older than ~5 minutes for replay protection. |
X-Spectrum-Signature | v0=<64-char hex> | HMAC-SHA256 of v0:{timestamp}:{rawBody} keyed by the webhook’s signing secret. See Verifying signatures. |
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. Theevent 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
Everyspace 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:
Common fields
Always present on every space, regardless of platform.
Common fields
Always present on every space, regardless of platform.
| Field | Type | Description |
|---|---|---|
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. |
platform | string | The platform that owns this space. See Providers for the current set of values; new platforms add new values without breaking existing payloads. |
iMessage-specific fields
Forwarded from the iMessage space schema (`type`, `phone`).
iMessage-specific fields
Forwarded from the iMessage space schema (`type`, `phone`).
| Field | Type | Description |
|---|---|---|
type | "dm" | "group" | Conversation kind. "dm" is a 1:1 chat with one phone number; "group" is a multi-recipient chat. |
phone | string | The iMessage line this conversation was received on. A dedicated line reports its E.164 number; a shared (pooled) line reports the literal shared. Treat it as an opaque label for which of your project’s lines the message landed on. |
Forward-compatible. New platform schema fields are forwarded automatically without a webhook deploy. Read the fields you care about; ignore the rest.
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
| Field | Type | Description |
|---|---|---|
id | string | Stable opaque identifier — treat it as opaque, don’t parse it. A plain message is spc-msg-<uuid>; a derived event carries a composite id (a reaction is spc-msg-<uuid>:reaction:<seq>:<idx>). Dedupe on it as-is. |
platform | string | The platform that sourced the message. Same value as space.platform. |
direction | "inbound" | Always "inbound" — outbound messages are not delivered as webhooks. |
timestamp | string | ISO 8601 UTC timestamp from the platform (when the user sent it). |
sender | { id, platform } | The user who sent the message. The id format is platform-defined (for iMessage it’s the E.164 phone number +15551234567; for WhatsApp Business it’s the WA contact id). |
space | object | A copy of the top-level space field, denormalized for convenience. Same shape — see Space above. |
content | object | The message content. Shape depends on the message type — see Content shapes below. |
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:
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:
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 |
video/* | Video clip | video/mp4, video/quicktime |
application/* | Document or file | application/pdf, application/zip |
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). Theattachment 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: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 |
contact | A contact card — all fields optional. Full shape: Contact. | 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 |
group | An album — multiple attachments (e.g. several photos) sent as one message. | items: SerializedInboundMessage[] — each entry is a full inbound message with its own content (typically attachment), id, sender, timestamp. Albums don’t nest. See example below. |
- Byte-bearing arms (
attachment,contact.photo) ship metadata only — see the warning above. The SDK’sread()/stream()thunks and the internal filesystempathare dropped before delivery. iMessage voice memos arrive asattachmentwith anaudio/*mimeType, not as a distinct arm. targetonreactionis the slim shape documented under Target refs below, not a full nested message.richlinkships onlyurl— OG fetches happen in your handler, since resolving them inline at delivery time would tie latency to the slowest target site.
group (album) nests each attachment as a full inbound message in items — here, two photos sent together:
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 plaintext (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
Thetarget 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:
| Field | Type | Description |
|---|---|---|
id | string | Stable opaque identifier of the referenced message. |
platform | string | Source platform — same value as the parent message’s platform. |
timestamp | string | ISO 8601 UTC timestamp of the referenced message. |
sender? | { id, platform } | The user who sent the referenced message, when known. |
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. |
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:
message.sender is who reacted; content.target.sender is who sent the message they reacted to.
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, runspectrum-tsin a separate process and reply through a liveSpace(from itsmessagesstream, 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:-
Branch on
eventdefensively. Use aswitchwith adefaultarm that returns2xxand logs the unknown event. Never crash on unknown values. - Treat extra fields as unknown but tolerable. New optional fields may appear in existing payload shapes. They’ll never repurpose existing fields.
-
Don’t subscribe to specific event types. Today every URL receives every event. If a
subscriptionsfield 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.