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

# Local webhook development

> Receive Tally webhooks against your laptop and iterate without redeploying.

Webhooks need a public URL Tally can POST to. That's awkward when your handler lives in a localhost-only dev server. This guide walks through the setup — tunneling, registration, verification, iteration — and the gotchas worth knowing about.

## The shape

```
Tally → [public tunnel] → your local server
```

You'll need a tunnel that gives you a stable public URL pointing at your local port. The two common options are **ngrok** and **cloudflared**; both are free for development. We'll use ngrok in the examples — the pattern is identical for cloudflared.

## 1. Install and start a tunnel

```bash theme={null}
# macOS
brew install ngrok
ngrok http 3000
```

ngrok prints something like:

```
Forwarding  https://abc123.ngrok-free.app -> http://localhost:3000
```

Copy that HTTPS URL. That's where Tally will deliver events.

## 2. Register the webhook in the dashboard

In Tally's dashboard, go to **Webhooks → New endpoint**:

1. **URL**: `https://abc123.ngrok-free.app/api/webhooks/tally` (or wherever your handler lives).
2. **Events**: pick what you need. For a payment flow, start with `payment.created`, `payment.confirmed`, and `payment.failed`.
3. **Signing secret**: copy this. It looks like `whsec_test_<base64url>`, shown exactly once. Save it to `.env.local`:

   ```bash theme={null}
   TALLY_WEBHOOK_SECRET=whsec_test_…
   ```

## 3. Write the handler

```ts theme={null}
// app/api/webhooks/tally/route.ts (Next.js App Router)
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("rejected webhook:", result.reason);
    return new Response("invalid signature", { status: 400 });
  }

  const event = JSON.parse(body);
  console.log("event:", event.type, event.data);

  switch (event.type) {
    case "payment.confirmed":
      // your business logic
      break;
    case "payment.failed":
      // your business logic
      break;
    default:
      // Ignore subscribed-to events we don't handle yet.
      break;
  }

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

Three things to get right:

* **Raw body, not parsed.** The HMAC is over the exact bytes Tally signed. Calling `req.json()` and re-stringifying breaks the match.
* **Return 2xx fast.** Tally times out at 10 seconds. If you have expensive downstream work, kick off a background job and return immediately.
* **Be idempotent.** Tally may deliver an event twice on retries. Key off `tally-delivery-id` or `id` from the payload, and short-circuit duplicates.

## 4. Trigger an event to test

The simplest way to test: send a real payment from another terminal.

```ts theme={null}
import { Tally } from "@tallyforagents/sdk";
const tally = new Tally({ apiKey: process.env.TALLY_API_KEY! });

await tally.payments.create({
  agent_id: "quickstart-bot",
  wallet: process.env.TALLY_WALLET_ADDRESS!,
  to: "0xC0fee04a1b2C3D4e5F6a7B8c9D0e1F2a3B4c5D6e",
  amount_usdc: "1.00",
});
```

Within a few seconds you should see `payment.created`, then `payment.confirmed` (or `payment.failed`) hit your handler.

## 5. Iterate with Replay

When your handler throws or returns a 5xx, Tally retries with backoff. While you're iterating, that's slow — you don't want to wait 1m → 5m → 15m for a retry while fixing a bug. Instead, use **Replay**:

1. Fix the bug in your handler.
2. In the dashboard, open the failed webhook's detail page.
3. Click **Replay** on the delivery row.

Replay creates a fresh delivery row (the original stays for audit) and the worker picks it up on the next pass — usually within a few seconds. You can replay the same event multiple times while iterating.

## Gotchas

### ngrok URL changes between sessions

The free tier gives you a different URL each time you restart ngrok. The webhook you registered against `abc123.ngrok-free.app` won't work tomorrow.

Workarounds:

* Use a paid ngrok plan with a reserved subdomain.
* Use cloudflared with a permanent Cloudflare tunnel.
* Re-register the webhook against the new URL each session.

For solo dev, the third option is fine; it takes 30 seconds.

### Body parsing middleware

Some frameworks parse the body automatically (Express with `body-parser`, etc.), which makes `req.text()` return an empty string. You'll need to opt out for the webhook route — most frameworks have a "give me the raw body" mode for this exact case.

Next.js App Router gets this right by default — `req.text()` returns the raw bytes.

### Clock skew

The signature includes a timestamp the verifier checks against (default tolerance: 5 minutes). If your laptop's clock is more than 5 minutes off, every signature will fail with `timestamp_out_of_tolerance`. Fix the clock; don't widen the tolerance.

## Going to production

Three things change when you move from local dev to a deployed environment:

1. **Switch to a real URL.** `https://api.yourapp.com/webhooks/tally`.
2. **Re-register** the webhook in the dashboard pointing at the production URL. If you used a `tly_test_` key for development, also register a separate `whsec_live_` endpoint when you opt into live mode.
3. **Move the signing secret to your production secret manager.** Same handler code; just a different env var source.

## Where to go from here

* [Webhooks (concept)](/webhooks) — event types, retry schedule, replay model.
* [SDK webhooks](/sdk/webhooks) — `verifySignature` reference.
* [Handle failures and retries](/guides/handling-failures) — how to keep your handler robust under load.
