Skip to main content

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

# 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:
    TALLY_WEBHOOK_SECRET=whsec_test_…
    

3. Write the handler

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