> ## Documentation Index
> Fetch the complete documentation index at: https://docs.photon.codes/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive messages via HTTP instead of a long-lived process

export const TypeTooltip = ({name, type, children}) => {
  const [visible, setVisible] = React.useState(false);
  const [pos, setPos] = React.useState({
    top: 0,
    left: 0
  });
  const triggerRef = React.useRef(null);
  const show = () => {
    if (triggerRef.current) {
      const rect = triggerRef.current.getBoundingClientRect();
      setPos({
        top: rect.bottom + 6,
        left: rect.left
      });
    }
    setVisible(true);
  };
  const hide = () => setVisible(false);
  return <>
      <span ref={triggerRef} onMouseEnter={show} onMouseLeave={hide} style={{
    cursor: "pointer",
    position: "relative",
    display: "inline"
  }}>
        {children || <code>{name}</code>}
      </span>
      {visible && <span style={{
    position: "fixed",
    top: pos.top,
    left: pos.left,
    zIndex: 9999,
    padding: "8px 12px",
    borderRadius: "8px",
    fontSize: "13px",
    lineHeight: "1.5",
    fontFamily: "'Azeret Mono', monospace",
    whiteSpace: "pre",
    backgroundColor: "var(--tw-prose-pre-bg, #1e1e1e)",
    color: "var(--tw-prose-pre-code, #e5e5e5)",
    border: "1px solid var(--border, rgba(128,128,128,0.2))",
    boxShadow: "0 4px 16px rgba(0,0,0,0.3)",
    pointerEvents: "none"
  }}>
          {type}
        </span>}
    </>;
};

`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 theme={null}
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 theme={null}
    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 theme={null}
    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. Install the adapter package and its framework only when you use it.

<Tabs>
  <Tab title="Hono">
    ```ts theme={null}
    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;
    ```

    <Accordion title="SpectrumPluginOptions" description="">
      | Option | Type | Default | Description |
      | ------ | ---- | ------- | ----------- |
    </Accordion>
  </Tab>

  <Tab title="Express">
    ```ts theme={null}
    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.

    <Accordion title="SpectrumPluginOptions" description="">
      | Option | Type | Default | Description |
      | ------ | ---- | ------- | ----------- |
    </Accordion>
  </Tab>

  <Tab title="Elysia">
    ```ts theme={null}
    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);
    ```

    <Accordion title="SpectrumPluginOptions" description="">
      | Option | Type | Default | Description |
      | ------ | ---- | ------- | ----------- |
    </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={`interface Message<TPlatform extends string = string, TSender extends User = User, TSpace extends Space = Space> {
  content: Content;
  direction: "inbound" | "outbound";
  edit(newContent: ContentInput): Promise<void>;
  readonly id: string;
  platform: TPlatform;
  react(reaction: string): Promise<(Message<TPlatform, AgentSender, TSpace> & {
    content: Reaction;
  }) | undefined>;
  read(): Promise<void>;
  reply(content: ContentInput): Promise<Message<TPlatform, AgentSender, TSpace> | undefined>;
  reply(...content: [
    ContentInput,
    ContentInput,
    ...ContentInput[]
  ]): Promise<Message<TPlatform, AgentSender, TSpace>[]>;
  sender: TSender | undefined;
  space: TSpace;
  timestamp: Date;
  unsend(): Promise<void>;
  }`} /> and <TypeTooltip name="Space" type={`interface Space<_Def = unknown> {
  readonly __platform: string;
  add(users: MemberInput): Promise<void>;
  avatar(input: string | URL, options?: {
    mimeType?: string;
  }): Promise<void>;
  avatar(input: Buffer, options: {
    mimeType: string;
  }): Promise<void>;
  edit(message: Message | undefined, newContent: ContentInput): Promise<void>;
  getAvatar(): Promise<AvatarData | undefined>;
  getDisplayName(): Promise<string | undefined>;
  getMembers(): Promise<User[]>;
  getMessage(id: string): Promise<Message | undefined>;
  readonly id: string;
  leave(): Promise<void>;
  read(message: Message): Promise<void>;
  remove(users: MemberInput): Promise<void>;
  rename(displayName: string): Promise<void>;
  responding<T>(fn: () => T | Promise<T>): Promise<T>;
  send(content: ReactionBuilder): Promise<(Message<string, AgentSender> & {
    content: Reaction;
  }) | undefined>;
  send(content: ContentInput): Promise<Message<string, AgentSender> | undefined>;
  send(...content: [
    ContentInput,
    ContentInput,
    ...ContentInput[]
  ]): Promise<Message<string, AgentSender>[]>;
  startTyping(): Promise<void>;
  stopTyping(): Promise<void>;
  unsend(message: Message | undefined): Promise<void>;
  }`} /> 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](/docs/webhooks/overview).
