-
Notifications
You must be signed in to change notification settings - Fork 165
Expand file tree
/
Copy pathwebhooks.mdx.vel
More file actions
227 lines (188 loc) · 9.51 KB
/
Copy pathwebhooks.mdx.vel
File metadata and controls
227 lines (188 loc) · 9.51 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
---
title: "Webhooks"
description: "Receive messages via HTTP instead of a long-lived process"
---
import { TypeTooltip } from "/snippets/type-tooltip.mdx";
{% set message = symbol("ts:spectrum-ts#Message") %}
{% set space = symbol("ts:spectrum-ts#Space") %}
{% set honoOptions = symbol("ts:@spectrum-ts/hono#SpectrumPluginOptions") %}
{% set expressOptions = symbol("ts:@spectrum-ts/express#SpectrumPluginOptions") %}
{% set elysiaOptions = symbol("ts:@spectrum-ts/elysia#SpectrumPluginOptions") %}
`app.webhook()` lets you receive messages through HTTP `POST` requests instead of the `app.messages` stream. It handles two webhook formats through the same method:
| | Native Spectrum webhook | Fusor webhook |
|---|---|---|
| Body | HMAC-signed, normalized JSON | Versioned JSON envelope (original provider request included) |
| Auth | HMAC over body, verified with `webhookSecret` | Platform's own signature via provider `verify()` |
| Requires a Fusor provider | No | Yes |
Fusor deliveries are selected by the CloudEvents header `ce-type: dev.spctrm.fusor.delivery`; every other request follows the native signed-webhook path. Your handler receives the same `(space, message)` pair either way.
## Configuring a webhook secret
Native Spectrum webhooks require a signing secret for HMAC verification. Pass it to `Spectrum()`:
```ts
const app = await Spectrum({
projectId: process.env.PROJECT_ID!,
projectSecret: process.env.PROJECT_SECRET!,
platforms: [imessage.config()],
webhookSecret: process.env.SPECTRUM_WEBHOOK_SECRET,
});
```
The `webhookSecret` option can also be supplied via the `SPECTRUM_WEBHOOK_SECRET` environment variable (the explicit option takes precedence). A native delivery that arrives without a configured secret is answered `500`.
## Receiving deliveries
Call `app.webhook()` from your HTTP server's `POST` route. The method has two overloads:
<Tabs>
<Tab title="Web Request (Hono / Bun.serve / Workers)">
```ts
server.post("/spectrum/webhook", (c) =>
app.webhook(c.req.raw, async (space, message) => {
if (message.content.type === "text") {
await space.send(`echo: ${message.content.text}`);
}
})
);
```
</Tab>
<Tab title="Raw (Express / Node)">
```ts
server.post(
"/spectrum/webhook",
express.raw({ type: "*/*" }),
async (req, res) => {
const result = await app.webhook(
{ body: req.body, headers: req.headers },
async (space, message) => {
if (message.content.type === "text") {
await space.send(`echo: ${message.content.text}`);
}
}
);
res.status(result.status).set(result.headers).send(Buffer.from(result.body));
}
);
```
</Tab>
</Tabs>
The handler is invoked **fire-and-forget** — it runs after the HTTP response is sent. A throw is logged, never surfaced. Dedupe on `message.id` for exactly-once side effects.
<Warning>
Pass the raw body bytes. The HMAC is computed over the exact bytes on the wire. If your framework parses the body to JSON and you re-stringify it, the bytes change and verification fails.
</Warning>
Fusor's schema-version `1` envelope is plain JSON. It exposes the original request's `method`, path (including query string), lower-case headers, a normalized `body` arm, and `rawBodyBase64`. The SDK validates the envelope and always passes the bytes decoded from `rawBodyBase64` to the provider's `verify()` function, so signatures are checked against the exact provider payload rather than reserialized JSON.
`request.bodyEncoding` describes the JSON-friendly `request.body` value:
| `bodyEncoding` | `request.body` |
|---|---|
| `json` | Any JSON value |
| `form` | An object of strings, with repeated form keys represented as ordered string arrays |
| `text` | A UTF-8 string |
| `base64` | A base64 string, identical to `rawBodyBase64` |
Low-code consumers can work directly with `request.body`. Signature-aware provider code should use the bytes decoded from `rawBodyBase64`; the SDK does this automatically before calling `verify()`.
## Framework adapters
First-party adapters mount the endpoint for you and handle raw-body parsing correctly. Install the adapter package and its framework only when you use it.
<Tabs>
<Tab title="Hono">
```ts
import { Hono } from "hono";
import { Spectrum } from "spectrum-ts";
import { imessage } from "spectrum-ts/providers/imessage";
import { spectrum } from "@spectrum-ts/hono";
const app = await Spectrum({
projectId: process.env.PROJECT_ID!,
projectSecret: process.env.PROJECT_SECRET!,
platforms: [imessage.config()],
webhookSecret: process.env.SPECTRUM_WEBHOOK_SECRET,
});
const server = new Hono().route(
"/",
spectrum({
app,
onMessage: async (space, message) => {
if (message.content.type === "text") {
await space.send(`echo: ${message.content.text}`);
}
},
})
);
export default server;
```
<Accordion title="SpectrumPluginOptions" description="{{ honoOptions.doc.summary }}">
| Option | Type | Default | Description |
|---|---|---|---|
{% for m in honoOptions.members -%}
| `{{ m.name }}{% if m.optional %}?{% endif %}` | `{{ m.type.text | replace("\n", " ") | replace(" ", "") | replace("|", "\\|") | replace("<", "<") | replace(">", ">") }}` | {% if m.doc.customTags["@default"] %}`{{ m.doc.customTags["@default"][0] }}`{% else %}—{% endif %} | {{ m.doc.summary | replace("\n", " ") | replace("|", "\\|") }} |
{% endfor %}
</Accordion>
</Tab>
<Tab title="Express">
```ts
import express from "express";
import { Spectrum } from "spectrum-ts";
import { imessage } from "spectrum-ts/providers/imessage";
import { spectrum } from "@spectrum-ts/express";
const app = await Spectrum({
projectId: process.env.PROJECT_ID!,
projectSecret: process.env.PROJECT_SECRET!,
platforms: [imessage.config()],
webhookSecret: process.env.SPECTRUM_WEBHOOK_SECRET,
});
const server = express();
server.use(
spectrum({
app,
onMessage: async (space, message) => {
if (message.content.type === "text") {
await space.send(`echo: ${message.content.text}`);
}
},
})
);
server.use(express.json());
server.listen(3000);
```
Mount the adapter **before** any global `express.json()`. A global JSON parser consumes the body stream first, breaking signature verification.
<Accordion title="SpectrumPluginOptions" description="{{ expressOptions.doc.summary }}">
| Option | Type | Default | Description |
|---|---|---|---|
{% for m in expressOptions.members -%}
| `{{ m.name }}{% if m.optional %}?{% endif %}` | `{{ m.type.text | replace("\n", " ") | replace(" ", "") | replace("|", "\\|") | replace("<", "<") | replace(">", ">") }}` | {% if m.doc.customTags["@default"] %}`{{ m.doc.customTags["@default"][0] }}`{% else %}—{% endif %} | {{ m.doc.summary | replace("\n", " ") | replace("|", "\\|") }} |
{% endfor %}
</Accordion>
</Tab>
<Tab title="Elysia">
```ts
import { Elysia } from "elysia";
import { Spectrum } from "spectrum-ts";
import { imessage } from "spectrum-ts/providers/imessage";
import { spectrum } from "@spectrum-ts/elysia";
const app = await Spectrum({
projectId: process.env.PROJECT_ID!,
projectSecret: process.env.PROJECT_SECRET!,
platforms: [imessage.config()],
webhookSecret: process.env.SPECTRUM_WEBHOOK_SECRET,
});
new Elysia()
.use(
spectrum({
app,
onMessage: async (space, message) => {
if (message.content.type === "text") {
await space.send(`echo: ${message.content.text}`);
}
},
})
)
.listen(3000);
```
<Accordion title="SpectrumPluginOptions" description="{{ elysiaOptions.doc.summary }}">
| Option | Type | Default | Description |
|---|---|---|---|
{% for m in elysiaOptions.members -%}
| `{{ m.name }}{% if m.optional %}?{% endif %}` | `{{ m.type.text | replace("\n", " ") | replace(" ", "") | replace("|", "\\|") | replace("<", "<") | replace(">", ">") }}` | {% if m.doc.customTags["@default"] %}`{{ m.doc.customTags["@default"][0] }}`{% else %}—{% endif %} | {{ m.doc.summary | replace("\n", " ") | replace("|", "\\|") }} |
{% endfor %}
</Accordion>
</Tab>
</Tabs>
## What the SDK handles
- **Signature verification.** Native webhooks are verified with `HMAC-SHA256` over `v0:<timestamp>:<rawBody>`, with a 5-minute replay window. Bad signature returns `401`, missing headers return `400`.
- **Payload deserialization.** Native webhook JSON is deserialized into normal <TypeTooltip name="Message" type={`{{ message.signature }}`} /> and <TypeTooltip name="Space" type={`{{ space.signature }}`} /> objects, including reactions and grouped items.
- **Attachment rehydration.** Native webhooks carry attachment metadata only. `read()` and `stream()` fetch the bytes lazily via the platform.
- **Format detection.** `ce-type: dev.spctrm.fusor.delivery` selects the Fusor v1 JSON path; all other requests use native HMAC verification.
## Delivery semantics
`app.webhook()` is stateless and request-scoped — it does **not** feed `app.messages`, and it never opens the streaming connection. Both formats deliver at-least-once, so dedupe on `message.id` for exactly-once side effects.
For more on Spectrum's webhook delivery model, see the [Webhooks documentation](/webhooks/overview).