Skip to main content
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

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

ngrok prints something like:
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:

3. Write the handler

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