Best Practices & Deduplication

How to make your webhook endpoint secure, idempotent and fast, plus answers to the questions merchants ask most.

These are the essential security and implementation practices for building secure, reliable webhook integrations.

Security

  • Always use a secure (HTTPS) URL. HTTP connections are unencrypted and vulnerable to man-in-the-middle attacks.
  • Verify the signature of every webhook and reject the ones that fail — see Verify signatures. An authentication header is an extra layer, not a replacement. Without Webhooks 2.0, webhooks are not signed: use an authentication method (BEARER, API_TOKEN or BASIC_AUTH).
  • Check the event against your records before fulfilling. The amount, currency and reference must match the order. A valid signature proves the webhook comes from Tonder, not that the payment covers the order.
  • Validate the event structure and content before processing: check that required fields are present and have the expected type.
  • Keep secrets on your server. The signing secret and your API key belong in environment variables or a secret manager — never in front-end or mobile code, your repository or your logs. Your API key also manages endpoints through the Webhooks API: whoever holds it can add an endpoint and receive your events. Rotate any secret you suspect is exposed.
  • Put credentials in a header, not in the URL. The endpoint URL is shown in the endpoint list; send tokens with an authentication method, not as a query parameter.
  • Answer with a short status. The first 2 KB of your server's response appear in the Events log; don't return stack traces, tokens or internal details.
  • If your firewall filters by IP, ask your Tonder contact for the webhooks' egress IP. An IP allowlist complements the signature; it doesn't replace it.

Implementation

  • Answer 2xx fast and process later, in a queue of your own. You have 10 seconds (30 without Webhooks 2.0).
  • Deduplicate on webhook-id. The same event can arrive more than once — a retry or a manual resend carries the same webhook-id. Without Webhooks 2.0, deduplicate on id + event_type. Correlate with metadata.external_id, which repeats on every event of the same transaction. Keep processed webhook-ids for at least 60 days, the time an event can still be resent — a unique constraint in your database does it.
  • Don't depend on arrival order. A payment_Pending can arrive after a payment_Success if it was retried. Use the payload's status and created.
  • Subscribe each endpoint only to the events it uses, and give separate systems separate endpoints.
  • Answer 2xx to events you don't handle, such as endpoint.test, and do nothing else with them.
  • Log what you need to trace an event — webhook-id, event_type, status, your order reference, the result and any error, with timestamps — and mask the rest. Payloads can carry personal and bank data (a SPEI payment includes the sender's name and CLABE). Never log your authentication header or the signing secret.
  • Handle errors gracefully. Wrap processing in try-catch blocks so a failed webhook doesn't bring down your whole application.

Deduplication checklist

  • Dedupe on the webhook-id header with Webhooks 2.0 — a retry or a resend carries the same one. Without Webhooks 2.0, dedupe on id + event_type. Correlate with metadata.external_id, which repeats on every event of a transaction and works across Short + Long formats.
  • Keep processed webhook-ids for at least 60 days — an event can be resent while it is kept.
  • Keep an idempotency_key index in your DB.
  • Treat duplicate Pending + Success in Hybrid mode as intentional.
  • Always verify final status via GET /api/v1/transactions/{id}/.
  • Handle all five withdrawal terminal states — Success, Declined, Cancelled, Failed and Returned; Returned arrives after Success.

Questions

I don't see the Events tab. Webhooks 2.0 is enabled per account: ask your Tonder contact. Meanwhile your endpoint receives every event, with no selection by type.

My server was down for a few hours. Did I lose events? Not if it came back before the 6 attempts ran out (about 8.5 hours). If any ended Failed, find it in the Events log and use Resend event or Retry all.

I unticked an event and I still receive it. The change applies to new events; the ones already being retried continue.

Why did I get endpoint.test if I didn't select it? It arrives only when someone uses Send test, whatever the endpoint subscribes to.

The counter reads 7/6. Manual resends add to the total attempts but not to the 6 automatic ones.

How long are events kept? 60 days.

Can I choose the format (Short or Long) per endpoint? No: your integration with Tonder sets it.

Next steps

Was this page helpful?

On this page