diff --git a/docs-src/spectrum-ts/getting-started.mdx.vel b/docs-src/spectrum-ts/getting-started.mdx.vel
index 1cc2dfa..7731a2d 100644
--- a/docs-src/spectrum-ts/getting-started.mdx.vel
+++ b/docs-src/spectrum-ts/getting-started.mdx.vel
@@ -90,11 +90,14 @@ const app = await Spectrum({
app.messages // AsyncIterable<[Space, Message]>
await app.send(space, ...) // send into a space
await app.responding(space, fn) // run fn with a typing indicator
+await app.webhook(req, handler) // handle an inbound webhook delivery
await app.stop() // graceful shutdown
```
Custom events emitted by providers are exposed as flat async iterables on the same object — see [Custom events and lifecycle](/spectrum-ts/custom-events-and-lifecycle).
+`app.webhook()` handles both native Spectrum webhooks and Fusor webhooks through the same method. See [Webhooks](/spectrum-ts/webhooks) for setup and framework adapters.
+
## Multi-platform in three lines
Combine providers to receive and send across platforms simultaneously:
diff --git a/docs-src/spectrum-ts/introduction.mdx.vel b/docs-src/spectrum-ts/introduction.mdx.vel
index bbd002a..43f2f2e 100644
--- a/docs-src/spectrum-ts/introduction.mdx.vel
+++ b/docs-src/spectrum-ts/introduction.mdx.vel
@@ -112,6 +112,9 @@ You do not have to manage Spectrum only from the dashboard. Use the dashboard fo
Install `spectrum-ts` and send your first message.
+
+ Receive messages via HTTP with native and Fusor webhook support, plus Hono, Express, and Elysia adapters.
+
Add a new interface with Spectrum's provider model.
diff --git a/docs-src/spectrum-ts/webhooks.mdx.vel b/docs-src/spectrum-ts/webhooks.mdx.vel
new file mode 100644
index 0000000..243f9a5
--- /dev/null
+++ b/docs-src/spectrum-ts/webhooks.mdx.vel
@@ -0,0 +1,192 @@
+---
+title: "Webhooks"
+description: "Receive messages via HTTP instead of a long-lived process"
+---
+
+import { TypeTooltip } from "/snippets/type-tooltip.mdx";
+
+`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 | Protobuf envelope (raw provider request) |
+| Auth | HMAC over body, verified with `webhookSecret` | Platform's own signature via provider `verify()` |
+| Requires a Fusor provider | No | Yes |
+
+Detection is by payload shape (JSON vs protobuf), not headers. 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!,
+ providers: [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:
+
+
+
+ ```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}`);
+ }
+ })
+ );
+ ```
+
+
+ ```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));
+ }
+ );
+ ```
+
+
+
+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.
+
+
+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.
+
+
+## Framework adapters
+
+First-party adapters mount the endpoint for you and handle raw-body parsing correctly. Each is an optional subpath import — install the framework as a peer dependency only if you use it.
+
+
+
+ ```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!,
+ providers: [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;
+ ```
+
+
+ ```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!,
+ providers: [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.
+
+
+ ```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!,
+ providers: [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);
+ ```
+
+
+
+All three adapters accept the same options:
+
+| Option | Type | Default | Description |
+|---|---|---|---|
+| `app` | `Spectrum` instance | — | The instance returned by `await Spectrum({...})`. |
+| `onMessage` | `(space, message) => void \| Promise` | — | Invoked once per inbound message, fire-and-forget. |
+| `path` | `string` | `"/spectrum/webhook"` | Route the endpoint is mounted on. |
+
+## What the SDK handles
+
+- **Signature verification.** Native webhooks are verified with `HMAC-SHA256` over `v0::`, with a 5-minute replay window. Bad signature returns `401`, missing headers return `400`.
+- **Payload deserialization.** Native webhook JSON is deserialized into normal `Message` / `Space` 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.** Native vs Fusor is detected per request by payload shape — JSON for native, protobuf for Fusor.
+
+## 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).
diff --git a/docs.json b/docs.json
index 2f789fc..a894278 100644
--- a/docs.json
+++ b/docs.json
@@ -35,6 +35,7 @@
"spectrum-ts/spaces-and-users",
"spectrum-ts/reactions-and-replies",
"spectrum-ts/platform-narrowing",
+ "spectrum-ts/webhooks",
{
"group": "Providers",
"pages": [
diff --git a/package.json b/package.json
index 3fbbcea..e51dd35 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,7 @@
"eslint-plugin-format": "^2.0.1",
"husky": "^9.1.7",
"oxfmt": "^0.44.0",
- "spectrum-ts": "3.1.0",
+ "spectrum-ts": "4.2.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f006a36..14208f2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -57,8 +57,8 @@ importers:
specifier: ^0.44.0
version: 0.44.0
spectrum-ts:
- specifier: 3.1.0
- version: 3.1.0(typescript@5.9.3)
+ specifier: 4.2.0
+ version: 4.2.0(typescript@5.9.3)
tsx:
specifier: ^4.21.0
version: 4.21.0
@@ -594,10 +594,6 @@ packages:
'@microsoft/tsdoc@0.15.1':
resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==}
- '@msgpack/msgpack@3.1.3':
- resolution: {integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==}
- engines: {node: '>= 18'}
-
'@opentelemetry/api-logs@0.216.0':
resolution: {integrity: sha512-KmGTgvxTJ0J01d4mOeX1wMV5NUTNf9HebIuOOGDfIn0a/IrnXIQbOnlylDyl9tkDv4h0DUpdI/GqCdLzfTkUXg==}
engines: {node: '>=8.0.0'}
@@ -1284,9 +1280,6 @@ packages:
asap@2.0.6:
resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==}
- async-mutex@0.5.0:
- resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==}
-
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -1308,12 +1301,6 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
- better-grpc@0.3.2:
- resolution: {integrity: sha512-e+u6C4zHwjE5g7vOvDpFeMe7Nas7FU+xa6FktiheRTcOpEdD5nag+uoIw7L5bPXdxxg995feBAXLwIay/npEqw==}
- engines: {node: '>=18.0.0'}
- peerDependencies:
- typescript: ^5
-
better-sqlite3@12.10.0:
resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==}
engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x}
@@ -2048,9 +2035,6 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
- it-pushable@3.2.4:
- resolution: {integrity: sha512-WSD7Ss4oCRfDZJT4ldLWr0Bom/muY90xxoJ5PQnU3uSKf0kxCOeehqZtiJX1ARqn+ymXGh1bxpDW9bDNHp2ivQ==}
-
jiti@2.6.1:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
@@ -2380,10 +2364,6 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
- p-defer@4.0.1:
- resolution: {integrity: sha512-Mr5KC5efvAK5VUptYEIopP1bakB85k2IWXaRC0rsh1uwn1L6M0LVml8OIQ4Gudg4oyZakf7FmeRLkMMtZW1i5A==}
- engines: {node: '>=12'}
-
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
@@ -2602,14 +2582,23 @@ packages:
spdx-license-ids@3.0.23:
resolution: {integrity: sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==}
- spectrum-ts@3.1.0:
- resolution: {integrity: sha512-Dv5rsXATxGUXFnKf3VPK0VpkMPyVkf4HHUYkti0V2AKhz2m+ut3I1UPNMsvZOsiqmF+5hW8Xvrw+u/I82+XcDA==}
+ spectrum-ts@4.2.0:
+ resolution: {integrity: sha512-2oCHMX+YjeRNaYI+aGamrK16k2v6I5MN83xgTKf8z1ouK4RklNohl+MH7+May9ZxMchr10ZkpdnsyVdUQenDrA==}
peerDependencies:
+ elysia: ^1
+ express: ^4 || ^5
ffmpeg-static: ^5
+ hono: ^4
typescript: ^5 || ^6.0.0
peerDependenciesMeta:
+ elysia:
+ optional: true
+ express:
+ optional: true
ffmpeg-static:
optional: true
+ hono:
+ optional: true
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
@@ -3254,8 +3243,6 @@ snapshots:
'@microsoft/tsdoc@0.15.1': {}
- '@msgpack/msgpack@3.1.3': {}
-
'@opentelemetry/api-logs@0.216.0':
dependencies:
'@opentelemetry/api': 1.9.1
@@ -3904,10 +3891,6 @@ snapshots:
asap@2.0.6: {}
- async-mutex@0.5.0:
- dependencies:
- tslib: 2.8.1
-
asynckit@0.4.0: {}
axios@1.15.0:
@@ -3927,15 +3910,6 @@ snapshots:
baseline-browser-mapping@2.10.18: {}
- better-grpc@0.3.2(typescript@5.9.3):
- dependencies:
- '@msgpack/msgpack': 3.1.3
- async-mutex: 0.5.0
- it-pushable: 3.2.4
- nice-grpc: 2.1.16
- typescript: 5.9.3
- zod: 4.4.3
-
better-sqlite3@12.10.0:
dependencies:
bindings: 1.5.0
@@ -4742,10 +4716,6 @@ snapshots:
isexe@2.0.0: {}
- it-pushable@3.2.4:
- dependencies:
- p-defer: 4.0.1
-
jiti@2.6.1: {}
js-yaml@4.1.1:
@@ -5288,8 +5258,6 @@ snapshots:
'@oxfmt/binding-win32-ia32-msvc': 0.44.0
'@oxfmt/binding-win32-x64-msvc': 0.44.0
- p-defer@4.0.1: {}
-
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
@@ -5576,7 +5544,7 @@ snapshots:
spdx-license-ids@3.0.23: {}
- spectrum-ts@3.1.0(typescript@5.9.3):
+ spectrum-ts@4.2.0(typescript@5.9.3):
dependencies:
'@photon-ai/advanced-imessage': 0.11.2
'@photon-ai/imessage-kit': 3.0.0
@@ -5586,12 +5554,9 @@ snapshots:
'@photon-ai/telegram-ts': 10.0.0
'@photon-ai/whatsapp-business': 0.1.1
'@repeaterjs/repeater': 3.0.6
- better-grpc: 0.3.2(typescript@5.9.3)
lru-cache: 11.5.1
marked: 18.0.5
mime-types: 3.0.2
- nice-grpc: 2.1.16
- nice-grpc-common: 2.0.3
open-graph-scraper: 6.11.0
type-fest: 5.6.0
typescript: 5.9.3
@@ -5678,7 +5643,8 @@ snapshots:
ts-error@1.0.6: {}
- tslib@2.8.1: {}
+ tslib@2.8.1:
+ optional: true
tsx@4.21.0:
dependencies: