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

# Webhooks

> tally.webhooks.verifySignature — validate incoming webhook deliveries.

The SDK's webhook surface is intentionally narrow today: a signature verifier you call inside your webhook handler. Endpoint management (create / list / revoke) is dashboard-only — see the [Webhooks concept](/webhooks).

## Types

### `WebhookVerifyInput`

```ts theme={null}
type WebhookVerifyInput = {
  /** Raw request body — string, exactly the bytes Tally signed.
   *  Don't pass a JSON-parsed object. */
  body: string;
  /** Value of the `tally-signature` header on the incoming request. */
  header: string | null | undefined;
  /** The plaintext signing secret saved at webhook creation
   *  (`whsec_<mode>_<base64url>`). */
  secret: string;
  /** Unix seconds. Defaults to now. Pin for tests. */
  now?: number;
  /** How far the header timestamp may drift from `now`. Defaults to 300s
   *  (5 minutes). */
  toleranceSeconds?: number;
};
```

### `WebhookVerifyResult`

```ts theme={null}
type WebhookVerifyResult =
  | { ok: true }
  | {
      ok: false;
      reason:
        | "missing_header"
        | "malformed_header"
        | "no_v1_signature"
        | "timestamp_out_of_tolerance"
        | "signature_mismatch";
    };
```

### `DEFAULT_TOLERANCE_SECONDS`

```ts theme={null}
const DEFAULT_TOLERANCE_SECONDS = 300; // 5 minutes
```

Exported for reference; use the `toleranceSeconds` field on `WebhookVerifyInput` to override.

## Methods

### `tally.webhooks.verifySignature(input)`

Verifies a `tally-signature` header against the raw request body.

```ts theme={null}
const result = tally.webhooks.verifySignature({
  body,
  header: req.headers.get("tally-signature"),
  secret: process.env.TALLY_WEBHOOK_SECRET!,
});

if (!result.ok) {
  console.warn("rejected webhook:", result.reason);
  return new Response("invalid signature", { status: 400 });
}
```

**Returns:** `WebhookVerifyResult`. Synchronous; no HTTP, no allocation beyond the HMAC buffer.

### `verifySignature(input)` (standalone)

The same function, exported directly for consumers who don't want to instantiate a `Tally` client just to verify a webhook.

```ts theme={null}
import { verifySignature } from "@tallyforagents/sdk";

const result = verifySignature({
  body,
  header: req.headers.get("tally-signature"),
  secret: process.env.TALLY_WEBHOOK_SECRET!,
});
```

Both forms are identical — pick whichever fits your dependency graph.

### `tally.webhooks.list()`

Returns every webhook endpoint in the API key's account + mode. Revoked endpoints are included (with `revoked_at` set) for audit.

```ts theme={null}
for (const w of await tally.webhooks.list()) {
  console.log(`${w.url} → ${w.events.join(", ")}`);
}
```

**Returns:** `Promise<Webhook[]>`.

### `tally.webhooks.create(input)`

Creates a new webhook endpoint. The signing secret is returned in `webhook.secret` **exactly once** — store it now; it's not recoverable later. If you lose it, revoke and recreate.

```ts theme={null}
const webhook = await tally.webhooks.create({
  url: "https://example.com/tally/events",
  events: ["payment.confirmed", "payment.failed"],
});

console.log("Secret (save it now):", webhook.secret);
```

**Parameters**

| Field          | Type                 | Description                                                       |
| -------------- | -------------------- | ----------------------------------------------------------------- |
| `input.url`    | `string`             | Receiver URL. Must be `https://` (or `http://localhost` for dev). |
| `input.events` | `WebhookEventType[]` | Event types to subscribe to.                                      |

**Returns:** `Promise<CreatedWebhook>` (a `Webhook` extended with `secret: string`).

### `tally.webhooks.revoke(id)`

Revokes a webhook endpoint by id. No further deliveries will be enqueued; deliveries already in-flight are not cancelled. Idempotent: revoking an already-revoked endpoint is a no-op.

```ts theme={null}
await tally.webhooks.revoke("whk_01HXYZ...");
```

**Returns:** `Promise<Webhook>` (the revoked webhook with `revoked_at` set).

## Full receiver example

Next.js App Router handler:

```ts theme={null}
import { verifySignature } from "@tallyforagents/sdk";

export async function POST(req: Request) {
  const body = await req.text(); // raw, not JSON-parsed

  const result = verifySignature({
    body,
    header: req.headers.get("tally-signature"),
    secret: process.env.TALLY_WEBHOOK_SECRET!,
  });

  if (!result.ok) {
    console.warn("webhook rejected:", result.reason);
    return new Response("invalid signature", { status: 400 });
  }

  const event = JSON.parse(body);

  switch (event.type) {
    case "payment.confirmed":
      await handlePaymentConfirmed(event.data);
      break;
    case "payment.failed":
      await handlePaymentFailed(event.data);
      break;
    case "inbound.received":
      await handleInbound(event.data);
      break;
    default:
      // Ignore subscribed-to events we don't handle yet. Don't return 5xx
      // for unrecognized types — Tally will retry forever.
      break;
  }

  return new Response("ok", { status: 200 });
}
```

## Why pass the raw body

The HMAC is computed over the exact bytes Tally signed — `<unix_ts>.<body>`. Any transformation (parsing, re-stringifying, whitespace normalization) breaks the match.

In most frameworks, getting the raw body is `await req.text()` or equivalent. **Don't** call `req.json()` and then `JSON.stringify` the result back — round-tripping through your runtime's JSON parser changes key ordering, whitespace, or both, depending on the runtime.

If you're behind a middleware or framework that consumes the body automatically (Express's `body-parser`, for example), you'll need to opt out for the webhook route. Most frameworks support a "give me the raw bytes" mode for this exact case.

## Rejection reasons

When `verifySignature` returns `{ ok: false, reason }`, the reason tells you exactly what failed:

| Reason                       | Meaning                                                                       | Likely cause                                                   |
| ---------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------- |
| `missing_header`             | No `tally-signature` header on the request.                                   | Wrong endpoint, or the proxy stripped headers.                 |
| `malformed_header`           | The header doesn't parse — couldn't extract `t=`.                             | Manual testing with the wrong format.                          |
| `no_v1_signature`            | The `v1=` segment is absent.                                                  | Same — manual tests, or a future signature version Tally adds. |
| `timestamp_out_of_tolerance` | The `t=` timestamp is more than `toleranceSeconds` away from now.             | Clock skew on either side, or a replayed payload.              |
| `signature_mismatch`         | The header parsed and the timestamp is in window, but the HMAC doesn't match. | Wrong secret, or the body was modified in transit.             |

Log the reason — it's the fastest path to diagnosing webhook failures.

## Tolerance window

The default 5-minute tolerance is calibrated for the realities of clock drift across servers and Tally's retry schedule (the first retry is 1 minute after the initial attempt). Don't widen it casually:

```ts theme={null}
verifySignature({
  body,
  header,
  secret,
  toleranceSeconds: 3600, // 1 hour — bad idea
});
```

A wider window means a stolen signature stays valid longer. If you're seeing legitimate deliveries fail `timestamp_out_of_tolerance`, fix the clock skew on your receiving server first.

## Not yet in the SDK

* `tally.webhooks.deliveries.list()` / `replay()` — delivery log access. Dashboard-driven today.
