Skip to main content
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 webhookFusor webhook
BodyHMAC-signed, normalized JSONProtobuf envelope (raw provider request)
AuthHMAC over body, verified with webhookSecretPlatform’s own signature via provider verify()
Requires a Fusor providerNoYes
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():
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:
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}`);
    }
  })
);
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.
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;
All three adapters accept the same options:
OptionTypeDefaultDescription
appSpectrum instanceThe instance returned by await Spectrum({...}).
onMessage(space, message) => void | Promise<void>Invoked once per inbound message, fire-and-forget.
pathstring"/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.