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

> React to payment, inbound, and permission events without polling.

Webhooks push events from Tally to your server when something interesting happens — a payment confirms, USDC arrives in a wallet, a user grants or revokes a permission. They're the alternative to polling `tally.payments.get()` in a loop.

## Mental model

Every event in Tally that you might care about reacting to (payments confirming, USDC arriving, grants changing) can be delivered as an HTTP POST to a URL you control. Tally signs every delivery with an HMAC so you can verify it actually came from us. Retries and replay are first-class — if your endpoint is down for an hour, we'll keep trying.

You pick which events you want, and configure one endpoint per "shape" of event you care about. Most products end up with one or two endpoints handling all the events they subscribe to.

## Event types

| Event                | When it fires                                                                                  |
| -------------------- | ---------------------------------------------------------------------------------------------- |
| `payment.created`    | Outbound payment signed by Privy and accepted by the network. Carries the broadcast `tx_hash`. |
| `payment.confirmed`  | Outbound payment mined and indexed. The chain receipt is canonical at this point.              |
| `payment.failed`     | Outbound payment reverted on-chain, or Privy refused to sign (policy violation, etc).          |
| `inbound.received`   | The inbound watcher detected USDC arriving at one of the account's wallets.                    |
| `permission.granted` | A user activated a new grant on a wallet (passkey-signed, on-chain).                           |
| `permission.revoked` | A user (or rotation flow) revoked a grant.                                                     |

Events are mode-scoped: a test-mode webhook only receives test-mode events; live-mode events only land on live-mode webhooks.

## Registering an endpoint

From the dashboard, **Webhooks → New endpoint**:

1. Enter the URL the events should POST to.
2. Pick the event types you want.
3. Copy the **signing secret** Tally shows you — it's revealed exactly once and looks like `whsec_test_<base64url>` (or `whsec_live_…`). Store it in your secret manager immediately.

That's it. Tally starts sending events to that endpoint as they happen.

Or, programmatically from the SDK:

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

See [SDK webhooks](/sdk/webhooks) for `list()` / `revoke()` too.

## Verifying the signature

Every delivery carries a `tally-signature` header in the format:

```
tally-signature: t=1715990400,v1=8b3f2a...
```

Where `t` is a unix timestamp and `v1` is `HMAC-SHA256(secret, "<t>.<raw_body>")` in lowercase hex.

The SDK ships a verifier so you don't have to implement this yourself:

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

const tally = new Tally({ apiKey: process.env.TALLY_API_KEY! });

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

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

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

  const event = JSON.parse(body);
  // ... handle event
}
```

The verifier returns `{ ok: true }` on success or `{ ok: false, reason }` on failure. The `reason` values are `missing_header`, `malformed_header`, `no_v1_signature`, `timestamp_out_of_tolerance`, or `signature_mismatch` — useful for logging exactly why a delivery was rejected.

If you'd rather not pull the full SDK, the verifier is also exported as a plain function:

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

<Warning>
  Pass the **raw request body** to the verifier, not a JSON-parsed object. The HMAC is over the exact bytes Tally signed — any whitespace change breaks the match.
</Warning>

## Additional headers

Alongside `tally-signature`, every delivery carries:

| Header              | Value                                                          |
| ------------------- | -------------------------------------------------------------- |
| `tally-event`       | The event type (e.g. `payment.confirmed`).                     |
| `tally-attempt`     | Attempt number, starting at 1.                                 |
| `tally-delivery-id` | Unique id for this delivery. Use for idempotency on your side. |

## Payload shape

```json theme={null}
{
  "id": "evt_01HXYZ...",
  "type": "payment.confirmed",
  "created": "2026-05-16T12:00:00Z",
  "mode": "test",
  "data": {
    "agent_id": "research-bot",
    "wallet": "0x7a3f...",
    "tx_hash": "0xabc...",
    "amount_usdc": "4.50",
    "to": "0xC0fee...",
    "memo": "arxiv API access"
  }
}
```

The `data` shape varies by event type. `payment.*` events include the payment record, `inbound.received` includes the receiving wallet + sender + amount, and `permission.*` events include the agent and wallet involved.

## Retries and timeouts

Tally retries non-2xx responses with exponential backoff: **1m → 5m → 15m → 1h → 6h** (6 attempts total). Each attempt has a **10-second timeout**. After the final attempt, the delivery is marked failed in the dashboard and can be replayed manually.

Anything in the 2xx range counts as success. Tally does not parse your response body.

## Replay

Failed deliveries can be replayed from the dashboard — open the webhook's detail page, find the failed delivery, click **Replay**. Replay creates a fresh delivery row (the original stays for audit) and the worker picks it up on the next pass.

Replay is also useful for testing: deliberately fail a delivery during development, fix the bug, replay against your now-working endpoint without manufacturing a fresh event.

## Best practices

* **Acknowledge fast.** Return 2xx within a few seconds. Defer expensive work to a background job — Tally's 10s timeout is generous, but a slow handler eats into your retry budget if the event triggers your downstream system.
* **Be idempotent.** Tally may deliver an event more than once (especially during retries near the timeout boundary). Key off `tally-delivery-id` or `id` from the payload, and short-circuit duplicates.
* **Reject events outside a tolerance window.** The verifier defaults to a 5-minute window on the `t=` timestamp. Don't widen it without thinking — a wider window makes a stolen signature replayable for longer.
* **Pin your endpoint's signing secret in a secret store**, not your repo. Rotation is dashboard-driven today; rotating the secret revokes the previous one immediately.
* **Subscribe deliberately.** Subscribing to events you don't handle wastes both Tally's delivery budget and your endpoint's CPU. If you only need `payment.confirmed`, subscribe only to that.

## What's not yet implemented

* **Webhook secret rotation with grace window.** Today, rotating regenerates and invalidates the previous secret immediately. The API-key-style 24h grace window is a candidate for parity.
* **Filtered subscriptions** — e.g. "only `payment.confirmed` events for agent `research-bot`." Today endpoints subscribe at the (account, mode, event\_type) level.
