Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs-src/spectrum-ts/getting-started.mdx.vel
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions docs-src/spectrum-ts/introduction.mdx.vel
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ You do not have to manage Spectrum only from the dashboard. Use the dashboard fo
<Card title="Get started" icon="rocket" href="/spectrum-ts/getting-started">
Install `spectrum-ts` and send your first message.
</Card>
<Card title="Webhooks" icon="webhook" href="/spectrum-ts/webhooks">
Receive messages via HTTP with native and Fusor webhook support, plus Hono, Express, and Elysia adapters.
</Card>
<Card title="Build a custom platform" icon="blocks" href="/spectrum-ts/custom-platforms">
Add a new interface with Spectrum's provider model.
</Card>
Expand Down
192 changes: 192 additions & 0 deletions docs-src/spectrum-ts/webhooks.mdx.vel
Original file line number Diff line number Diff line change
@@ -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:

<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>

## 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.

<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!,
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;
```
</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!,
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.
</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!,
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);
```
</Tab>
</Tabs>

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<void>` | — | 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:<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 `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).
1 change: 1 addition & 0 deletions docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"spectrum-ts/spaces-and-users",
"spectrum-ts/reactions-and-replies",
"spectrum-ts/platform-narrowing",
"spectrum-ts/webhooks",
{
"group": "Providers",
"pages": [
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Loading
Loading