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

# @tolt/webhooks

> Verify Tolt webhook signatures and get a typed event back. Node 18+, zero dependencies.

`verifyWebhook()` checks that a delivery really came from Tolt, then hands you the event, typed by `event.type`.

&#x20;Tolt
POST /api/webhooks/tolt
&#x20;verifyWebhook()

Returned when valid

**event**ToltWebhookEventtype"commission.created"data.id"comm\_2nV8…"data.amount1470timestamp"2026-10-19T12:15…"

Thrown when invalid

**WebhookVerificationError**400svix-idmissingsvix-timestamp> 300s oldsvix-signatureno match

## Install

```bash theme={"system"}
npm install @tolt/webhooks
```

Copy your endpoint's signing secret (`whsec_...`) from **Dashboard → Settings → Webhooks** into `TOLT_WEBHOOK_SECRET`.

## Verify a delivery

Always pass the **raw** request body. A parsed and re-serialized body won't match the signature.

**Next.js**

```ts app/api/webhooks/tolt/route.ts theme={"system"}
import {
  verifyWebhook,
  WebhookVerificationError,
  type ToltWebhookEvent,
} from "@tolt/webhooks";

export const runtime = "nodejs";

export async function POST(req: Request) {
  let event: ToltWebhookEvent;
  try {
    event = verifyWebhook({
      payload: await req.text(),
      headers: req.headers,
      secret: process.env.TOLT_WEBHOOK_SECRET!,
    });
  } catch (err) {
    if (err instanceof WebhookVerificationError) {
      return new Response("Invalid signature", { status: 400 });
    }
    throw err;
  }

  // handle event (see below)
  return new Response("ok");
}
```

**Express**

```ts server.ts theme={"system"}
import express from "express";
import { verifyWebhook, WebhookVerificationError } from "@tolt/webhooks";

const app = express();

app.post(
  "/api/webhooks/tolt",
  express.raw({ type: "application/json" }),
  (req, res) => {
    try {
      const event = verifyWebhook({
        payload: req.body.toString("utf8"),
        headers: req.headers,
        secret: process.env.TOLT_WEBHOOK_SECRET!,
      });
      // handle event (see below)
      res.sendStatus(200);
    } catch (err) {
      if (err instanceof WebhookVerificationError) {
        return res.sendStatus(400);
      }
      throw err;
    }
  },
);
```

Register this route before any global `express.json()`, or the body arrives already parsed.

## Handle the event

Narrow on `event.type` and `event.data` is typed for that resource.

```ts theme={"system"}
switch (event.type) {
  case "commission.created":
    await notifyPartner(event.data.partner_id, event.data.amount);
    break;
  case "customer.created":
    await tagReferredUser(event.data.email, event.data.partner_id);
    break;
  case "transaction.updated":
    if (event.data.status === "refunded") {
      await flagRefund(event.data.id);
    }
    break;
}
```

Every event is `{ type, data, timestamp }`. `data` has the same fields as the API resource, plus `organization_id`. The types are: `partner`, `link`, `customer`, `transaction` and `commission`, each with `.created` and `.updated`. See [Event types](/webhooks/event-types).

> [!TIP]
>
> Deliveries can be retried, so the same event may arrive twice. Deduplicate on the `svix-id` header.

## Options

**payload** `string` *required*

The raw request body, exactly as received.

**headers** `Headers | Record\<string, string | string[]\>` *required*

The request headers: a Fetch `Headers` object or a plain object such as Express's `req.headers`.

**secret** `string` *required*

Your endpoint's signing secret, `whsec_...`.

**toleranceInSeconds** `number` (default: 300)

How far the `svix-timestamp` may be from now before the delivery is rejected as a replay.

## Errors

`verifyWebhook()` throws `WebhookVerificationError` and returns nothing when the delivery can't be trusted. Respond `400`.

| Message | Cause |
| - | - |
| `Missing one of the signature headers.` | No `svix-id`, `svix-timestamp` or `svix-signature`. |
| `Invalid timestamp header.` | `svix-timestamp` is not a number. |
| `Timestamp is outside the allowed tolerance (possible replay).` | Older or newer than `toleranceInSeconds`. |
| `Signature does not match.` | Wrong secret, or the body was modified (often by a JSON parser). |
| `Payload is not valid JSON.` | The body verified but isn't JSON. |

> **Verify without the package**
>
> The signature is HMAC-SHA256, and you can check it with any crypto library:
>
> 1. Read `svix-id`, `svix-timestamp` and `svix-signature`. Reject if the timestamp is more than 5 minutes from now.
> 2. Base64-decode the part of your secret after `whsec_`. That is the key.
> 3. Sign the string `{svix-id}.{svix-timestamp}.{raw body}` with it, and base64-encode the digest.
> 4. `svix-signature` is a space-separated list like `v1,<base64>`. Accept if any signature after the comma equals yours, compared in constant time.
>
> ```python Python theme={"system"}
> import base64, hashlib, hmac, time
>
>
> def verify(payload: bytes, headers, secret: str) -> None:
>     msg_id, ts = headers["svix-id"], headers["svix-timestamp"]
>     if abs(time.time() - int(ts)) > 300:
>         raise ValueError("stale timestamp")
>     key = base64.b64decode(secret.split("_", 1)[1])
>     signed = f"{msg_id}.{ts}.".encode() + payload
>     expected = base64.b64encode(
>         hmac.new(key, signed, hashlib.sha256).digest()
>     ).decode()
>     signatures = [
>         s.split(",", 1)[-1] for s in headers["svix-signature"].split(" ")
>     ]
>     if not any(hmac.compare_digest(expected, s) for s in signatures):
>         raise ValueError("bad signature")
> ```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.