Skip to content

Commit 88dcba3

Browse files
authored
Merge pull request #25 from photon-hq/fix-webhooks-docs
fix: fix webhook docs for semantics and accuracy
2 parents 4b5ceeb + 358b842 commit 88dcba3

6 files changed

Lines changed: 97 additions & 57 deletions

File tree

api-reference/endpoint/webhook.mdx

Lines changed: 0 additions & 4 deletions
This file was deleted.

webhooks/events.mdx

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,21 @@ X-Spectrum-Signature: v0=3a1f7c8b2d9e0a4f6e3c5b8a1d2e4f6a8b0c3d5e7f9a1b2c4d6e8f0
2020
{
2121
"event": "messages",
2222
"space": {
23-
"id": "imessage:chat:42",
24-
"platform": "imessage"
23+
"id": "any;-;+15550100",
24+
"platform": "iMessage"
2525
},
2626
"message": {
27-
"id": "imessage:msg:abc123",
28-
"platform": "imessage",
27+
"id": "spc-msg-00000000-0000-4000-8000-000000000001",
28+
"platform": "iMessage",
2929
"direction": "inbound",
3030
"timestamp": "2026-05-14T19:06:32.000Z",
3131
"sender": {
32-
"id": "imessage:+15551234567",
33-
"platform": "imessage"
32+
"id": "+15550100",
33+
"platform": "iMessage"
3434
},
3535
"space": {
36-
"id": "imessage:chat:42",
37-
"platform": "imessage"
36+
"id": "any;-;+15550100",
37+
"platform": "iMessage"
3838
},
3939
"content": {
4040
"type": "text",
@@ -83,10 +83,10 @@ This is the only event currently emitted. It fires once per inbound message that
8383

8484
| Field | Type | Description |
8585
| --- | --- | --- |
86-
| `id` | `string` | Stable, platform-prefixed identifier (e.g. `imessage:chat:42`). |
87-
| `platform` | `string` | The platform that owns this space (`imessage`, `whatsapp_business`). |
86+
| `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. |
87+
| `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. |
8888

89-
The `space.id` matches the `space.id` you'd see from the [`spectrum-ts` SDK](/spectrum-ts/spaces-and-users). You can use it to send a reply back via the [Spectrum API](/api-reference/introduction).
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.
9090

9191
#### Message
9292

@@ -100,6 +100,25 @@ The `space.id` matches the `space.id` you'd see from the [`spectrum-ts` SDK](/sp
100100
| `space` | object | A copy of the top-level `space` field, denormalized for convenience. |
101101
| `content` | object | The message content. Shape depends on the message type — see below. |
102102

103+
#### Idempotency: the `message.id` rule
104+
105+
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`.
106+
107+
That makes `message.id` the right dedup key when one downstream consumer handles every webhook for the project:
108+
109+
```ts
110+
const dedupeKey = payload.message.id;
111+
if (await store.exists(dedupeKey)) return new Response('ok', { status: 200 });
112+
await processOnce(payload);
113+
await store.set(dedupeKey, true, { ttl: 48 * 60 * 60 });
114+
```
115+
116+
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:
117+
118+
```ts
119+
const dedupeKey = `${webhookId}:${payload.message.id}`;
120+
```
121+
103122
#### Content shapes
104123

105124
`content` is a discriminated union tagged by `type`. It mirrors the [`message.content` shape](/spectrum-ts/content) from the `spectrum-ts` SDK.
@@ -122,7 +141,7 @@ Always handle unknown `content.type` values gracefully — new content types may
122141
123142
A few things that may be in the SDK's `Message` type but are intentionally **not** in the webhook payload:
124143
125-
- **Methods like `.reply()` or `.react()`.** They depend on a live SDK connection. To respond, call the [Spectrum API](/api-reference/introduction) using `space.id`.
144+
- **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.
126145
- **Internal provider state.** Things like raw protocol headers, retry hints, and message acknowledgements are stripped before serialization.
127146
- **Outbound messages.** Webhooks deliver inbound only. A message you sent does not echo back as a webhook.
128147

webhooks/managing-webhooks.mdx

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,25 @@ You manage webhooks through three HTTP endpoints on the Spectrum API. All requir
77

88
## Authentication
99

10-
Every request uses HTTP Basic auth where the username is your `projectId` and the password is your `projectSecret`.
10+
Every request uses HTTP Basic auth where the username is your `projectId` and the password is your `projectSecret`. The `projectId` also appears in the URL path — both are required.
1111

1212
```sh
13-
curl -u "$PROJECT_ID:$PROJECT_SECRET" https://spectrum.photon.codes/webhooks/
13+
curl -u "$PROJECT_ID:$PROJECT_SECRET" \
14+
"https://spectrum.photon.codes/projects/$PROJECT_ID/webhooks/"
1415
```
1516

1617
Project credentials are scoped to a single project. They never expire — rotate them via `photon projects regenerate-secret <id>` (see the [CLI projects docs](/cli/projects#rotate-the-spectrum-api-secret)) if they leak.
1718

1819
## Register a webhook
1920

2021
```sh
21-
curl -X POST https://spectrum.photon.codes/webhooks/ \
22+
curl -X POST "https://spectrum.photon.codes/projects/$PROJECT_ID/webhooks/" \
2223
-u "$PROJECT_ID:$PROJECT_SECRET" \
2324
-H "Content-Type: application/json" \
2425
-d '{"webhookUrl":"https://your-app.com/spectrum-webhook"}'
2526
```
2627

27-
Response (`201 Created`):
28+
Response (`200 OK`):
2829

2930
```json
3031
{
@@ -47,21 +48,21 @@ The `signingSecret` is **only returned in this response**. There is no `GET` end
4748

4849
| Status | When it happens | What to do |
4950
| --- | --- | --- |
50-
| `400` | Invalid `webhookUrl` (not HTTPS, malformed) | Use a valid `https://` URL |
51+
| `422` | `webhookUrl` fails schema validation (empty, malformed, etc.) | Send a syntactically valid URL string |
5152
| `409` | The same URL is already registered for this project | List existing webhooks, or delete the old one and re-register |
5253
| `401` | Bad project credentials | Rotate via the CLI and try again |
5354

5455
### URL requirements
5556

56-
- Must be `https://`.
57-
- Must be reachable from the public internet — this isn't strict, but if Spectrum can't reach it, every delivery fails.
57+
- Should be `https://`. We accept `http://` URLs today and don't reject them at registration, but delivery is then sent in plaintext — anyone on the network path can read the payload and forge requests. Treat HTTPS as a hard requirement for any non-toy webhook.
58+
- Must be reachable from the public internet — if Spectrum can't reach it, every delivery fails after the retry budget exhausts.
5859
- Path component is yours to choose; we POST to it as-is.
5960

6061
## List registered webhooks
6162

6263
```sh
6364
curl -u "$PROJECT_ID:$PROJECT_SECRET" \
64-
https://spectrum.photon.codes/webhooks/
65+
"https://spectrum.photon.codes/projects/$PROJECT_ID/webhooks/"
6566
```
6667

6768
Response:
@@ -97,7 +98,7 @@ The list response **does not include `signingSecret`**. It's only ever returned
9798
```sh
9899
curl -X DELETE \
99100
-u "$PROJECT_ID:$PROJECT_SECRET" \
100-
https://spectrum.photon.codes/webhooks/6a4d2e8c-7b1f-4d3a-9a8e-2c5d6f7e8a9b
101+
"https://spectrum.photon.codes/projects/$PROJECT_ID/webhooks/6a4d2e8c-7b1f-4d3a-9a8e-2c5d6f7e8a9b/"
101102
```
102103

103104
Response:
@@ -125,7 +126,7 @@ There is no dedicated rotation endpoint. To rotate, **delete and re-register**:
125126
OLD_ID=6a4d2e8c-7b1f-4d3a-9a8e-2c5d6f7e8a9b
126127

127128
# 2. Register the same URL — get a new secret
128-
curl -X POST https://spectrum.photon.codes/webhooks/ \
129+
curl -X POST "https://spectrum.photon.codes/projects/$PROJECT_ID/webhooks/" \
129130
-u "$PROJECT_ID:$PROJECT_SECRET" \
130131
-H "Content-Type: application/json" \
131132
-d '{"webhookUrl":"https://your-app.com/spectrum-webhook"}'
@@ -135,7 +136,7 @@ curl -X POST https://spectrum.photon.codes/webhooks/ \
135136

136137
# 4. Delete the old webhook
137138
curl -X DELETE -u "$PROJECT_ID:$PROJECT_SECRET" \
138-
https://spectrum.photon.codes/webhooks/$OLD_ID
139+
"https://spectrum.photon.codes/projects/$PROJECT_ID/webhooks/$OLD_ID/"
139140
```
140141

141142
<Tip>
@@ -176,8 +177,11 @@ A first-class `photon webhooks` CLI is on the roadmap. Until then, wrap the curl
176177
```sh
177178
# A small helper to list webhooks for the active project
178179
list_webhooks() {
179-
local creds=$(photon projects show --json | jq -r '"\(.id):\(.secret)"')
180-
curl -s -u "$creds" https://spectrum.photon.codes/webhooks/
180+
local row creds id
181+
row=$(photon projects show --json)
182+
id=$(echo "$row" | jq -r '.id')
183+
creds=$(echo "$row" | jq -r '"\(.id):\(.secret)"')
184+
curl -s -u "$creds" "https://spectrum.photon.codes/projects/$id/webhooks/"
181185
}
182186
```
183187

webhooks/overview.mdx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
---
22
title: Webhooks
3-
description: Receive iMessage and WhatsApp events at your own URL — Spectrum signs each delivery so you know it's real
3+
description: Receive messaging events at your own URL — Spectrum signs each delivery so you know it's real
44
---
55

66
Spectrum webhooks push platform events — incoming messages, and (soon) more — to a URL you control. You register the URL once, and from that moment on every message that lands for your project is delivered to your server as a signed HTTP `POST`.
@@ -21,7 +21,7 @@ X-Spectrum-Timestamp: 1747242392
2121
{"event":"messages","space":{...},"message":{...}}
2222
```
2323

24-
You write a normal HTTP handler in whatever framework you already use. Spectrum handles staying connected to iMessage and WhatsApp Business, batching, reconnects, and signing.
24+
You write a normal HTTP handler in whatever framework you already use. Spectrum handles staying connected to every [supported platform](/spectrum-ts/providers), batching, reconnects, and signing.
2525

2626
## When to use webhooks
2727

@@ -47,7 +47,7 @@ flowchart LR
4747
Retry --> URL2
4848
```
4949

50-
When a message arrives on iMessage or WhatsApp Business for a project that has webhooks registered:
50+
When a message arrives on any enabled platform for a project that has webhooks registered:
5151

5252
1. Our worker receives the message from the platform.
5353
2. It serializes the event to JSON.

webhooks/quickstart.mdx

Lines changed: 37 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ If you already have a deployed HTTPS URL, skip the ngrok step.
99

1010
## Prerequisites
1111

12-
- A Spectrum project with at least one platform enabled (iMessage or WhatsApp Business). If you don't have one yet, see [Getting Started with Spectrum](/spectrum-ts/getting-started).
12+
- A Spectrum project with at least one platform enabled. See [Providers](/spectrum-ts/providers) for the current list, or [Getting Started with Spectrum](/spectrum-ts/getting-started) if you don't have a project yet.
1313
- Your project id and project secret, from the [dashboard](https://app.photon.codes) or `photon projects show`.
1414
- A reachable HTTPS URL — ngrok works for local development.
1515

@@ -51,7 +51,7 @@ If you already have a deployed HTTPS URL, skip the ngrok step.
5151
Use `curl` (or any HTTP client) to register the URL with your project credentials:
5252

5353
```sh
54-
curl -X POST https://spectrum.photon.codes/webhooks/ \
54+
curl -X POST "https://spectrum.photon.codes/projects/$PROJECT_ID/webhooks/" \
5555
-u "$PROJECT_ID:$PROJECT_SECRET" \
5656
-H "Content-Type: application/json" \
5757
-d '{"webhookUrl":"https://abcd1234.ngrok-free.app/spectrum-webhook"}'
@@ -144,10 +144,10 @@ If you already have a deployed HTTPS URL, skip the ngrok step.
144144

145145
<Step title="Send a real message">
146146

147-
From your phone, send an iMessage (or WhatsApp message) to the number attached to your project. Within a second or two, your terminal should print:
147+
Send a real message to your project from any of its enabled platforms — an iMessage to the assigned number, a WhatsApp message, whatever you've configured. Within a second or two, your terminal should print:
148148

149149
```text
150-
message from imessage:+15551234567 : { type: 'text', text: 'hi' }
150+
message from +15550100 : { type: 'text', text: 'hi' }
151151
```
152152

153153
If you see `bad signature`, double-check that you exported `SPECTRUM_SIGNING_SECRET` correctly and re-started the server.
@@ -157,22 +157,42 @@ If you already have a deployed HTTPS URL, skip the ngrok step.
157157

158158
<Step title="Reply from your handler (optional)">
159159

160-
The webhook delivery only carries the inbound message. To reply, call the [Spectrum API](/api-reference/introduction) (or run a separate `Spectrum()` instance for sending). A typical pattern:
160+
The webhook delivery only carries inbound messages — there is no public HTTP "send a message" endpoint today. To reply, run the [`spectrum-ts`](/spectrum-ts/getting-started) SDK in a separate process (or alongside your handler) and call `space.send(...)` there. The webhook tells your service *what* arrived; the SDK is what puts a message back on the wire.
161+
162+
A common split:
161163

162164
```ts
163-
if (event === 'messages' && payload.message.content.type === 'text') {
164-
await fetch(`https://spectrum.photon.codes/spaces/${payload.space.id}/messages`, {
165-
method: 'POST',
166-
headers: {
167-
Authorization: `Basic ${btoa(`${process.env.PROJECT_ID}:${process.env.PROJECT_SECRET}`)}`,
168-
'Content-Type': 'application/json',
169-
},
170-
body: JSON.stringify({ content: { type: 'text', text: 'hi back' } }),
171-
});
172-
}
165+
// sender.ts — long-lived process holding an outbound SDK instance
166+
import { Spectrum, text } from "spectrum-ts";
167+
import { imessage } from "spectrum-ts/providers/imessage";
168+
169+
const app = await Spectrum({
170+
projectId: process.env.PROJECT_ID!,
171+
projectSecret: process.env.PROJECT_SECRET!,
172+
providers: [imessage.config()],
173+
});
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);
178+
await space.send(text(body));
179+
};
180+
```
181+
182+
Inside the webhook handler, acknowledge with `2xx` first (the worker treats a slow response as a timeout and retries) and enqueue the reply job:
183+
184+
```ts
185+
app.post('/spectrum-webhook', async (c) => {
186+
if (!verify(c)) return c.text('bad signature', 401);
187+
const payload = JSON.parse(await c.req.text());
188+
if (payload.event === 'messages' && payload.message.content.type === 'text') {
189+
void enqueueReply(payload.space.id, `echo: ${payload.message.content.text}`);
190+
}
191+
return c.text('ok', 200);
192+
});
173193
```
174194

175-
Acknowledge the webhook (`return c.text('ok', 200)`) **before** the reply call if your reply might take >1s — the worker treats a slow response as a timeout and retries.
195+
An HTTP send-message API is on the roadmap; until then, the SDK is the supported path.
176196
</Step>
177197
</Steps>
178198

@@ -181,7 +201,7 @@ If you already have a deployed HTTPS URL, skip the ngrok step.
181201
You have an end-to-end pipeline:
182202

183203
```text
184-
phone → iMessage → Spectrum → POST /spectrum-webhook → your code
204+
user's device → platform → Spectrum → POST /spectrum-webhook → your code
185205
```
186206

187207
You verified each delivery is genuine (not spoofed), recent (not a replay), and unmodified (not tampered with).

webhooks/troubleshooting.mdx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ Walk through this checklist in order:
5757
<Steps>
5858
<Step title="Confirm the webhook is registered">
5959
```sh
60-
curl -u "$PROJECT_ID:$PROJECT_SECRET" https://spectrum.photon.codes/webhooks/
60+
curl -u "$PROJECT_ID:$PROJECT_SECRET" \
61+
"https://spectrum.photon.codes/projects/$PROJECT_ID/webhooks/"
6162
```
6263

6364
The URL you expect should appear in the list. If not, register it.
@@ -76,7 +77,7 @@ Walk through this checklist in order:
7677
photon spectrum platforms ls
7778
```
7879

79-
An iMessage line that's not paired or a WhatsApp Business token that's expired produces zero inbound events. Webhooks deliver what the SDK receives if the SDK is silent, webhooks are silent.
80+
A platform that's enabled in the dashboard but not actually connected — an unpaired iMessage line, an expired WhatsApp token, a custom provider whose lifecycle handler is throwing — produces zero inbound events. Webhooks deliver what the SDK receives, so if the SDK is silent for a platform, that platform's webhooks are silent too. Check the SDK side first.
8081
</Step>
8182

8283
<Step title="Confirm the message is actually inbound to your project">
@@ -153,7 +154,7 @@ Free ngrok tunnels get a new URL every restart. That URL won't be registered wit
153154
```sh
154155
ngrok http 3000
155156
# Copy the new URL, then:
156-
curl -X POST https://spectrum.photon.codes/webhooks/ \
157+
curl -X POST "https://spectrum.photon.codes/projects/$PROJECT_ID/webhooks/" \
157158
-u "$PROJECT_ID:$PROJECT_SECRET" \
158159
-H "Content-Type: application/json" \
159160
-d '{"webhookUrl":"https://NEW-URL.ngrok-free.app/spectrum-webhook"}'
@@ -182,14 +183,14 @@ import { createHmac } from 'node:crypto';
182183
const secret = 'a3f8e29b...5c7e9b2d';
183184
const body = JSON.stringify({
184185
event: 'messages',
185-
space: { id: 'imessage:chat:1', platform: 'imessage' },
186+
space: { id: 'any;-;+15550100', platform: 'iMessage' },
186187
message: {
187-
id: 'imessage:msg:test1',
188-
platform: 'imessage',
188+
id: 'spc-msg-00000000-0000-4000-8000-000000000001',
189+
platform: 'iMessage',
189190
direction: 'inbound',
190191
timestamp: new Date().toISOString(),
191-
sender: { id: 'imessage:+15551234567', platform: 'imessage' },
192-
space: { id: 'imessage:chat:1', platform: 'imessage' },
192+
sender: { id: '+15550100', platform: 'iMessage' },
193+
space: { id: 'any;-;+15550100', platform: 'iMessage' },
193194
content: { type: 'text', text: 'hi' },
194195
},
195196
});

0 commit comments

Comments
 (0)