Handling payment webhooks: signatures, retries and duplicates
A payment webhook is how your till learns a card was approved. How to verify it really came from Revoluno, survive retries without double-counting, and recover when one goes missing.
With a cloud terminal, the webhook is the moment of truth: it tells your till whether the customer paid. Treat it like money arriving, because that is what it is. This guide covers the three things every receiver must get right.
1. Verify the signature
Anyone who learns your webhook URL can post to it. Every Revoluno webhook carries a signature header:
X-Revoluno-Signature: t=1792947972,v1=176a4bc5…
v1 is the hex HMAC-SHA256 of the timestamp, a dot and the raw request body, keyed with your webhook signing secret (whsec_…, under Developers in the console). To check it:
- Read the body exactly as received, as bytes or a string, before any JSON parsing. Re-serialised JSON won't match.
- Compute
HMAC-SHA256(secret, t + "." + body)and compare it withv1in constant time. - Reject timestamps more than five minutes from now, so an old captured request can't be replayed.
import crypto from 'node:crypto';
function verify(rawBody, header, secret) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = crypto.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`).digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1 ?? ''));
}
The API reference has the same check in Python, PHP, C# and Java.
2. Expect retries, and drop duplicates
If your endpoint doesn't answer with a 2xx within 10 seconds, the webhook is retried after 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours. A retry can also arrive after you processed the first copy, for example when your server finished the work but the response was lost.
Every retry carries the same event id. Store the ids you have processed, and answer 200 without doing anything when one comes back. In SQL a unique index on the event id does the job:
INSERT INTO processed_events (event_id) VALUES (?)
-- duplicate key? it was handled already: return 200
3. Answer fast, work later
Ten seconds is plenty to verify, store the event and return 200. Don't print receipts, call other services or send emails before answering. Put that work on a queue, or do it after the response.
Late approvals
Very occasionally a terminal reports a result after its checkout timed out. If that result is an approval, the customer's card was charged, so the checkout flips from failed/EXPIRED to successful, and you get a second webhook. Key your logic on client_transaction_id and always apply the latest status.
When a webhook never arrives
- Poll as a fallback. If the cashier's screen has waited longer than expected, fetch
GET /checkouts/{client_transaction_id}. - Reconcile daily. List the day's checkouts with
GET /checkouts?status=successfuland compare them with your sales. - Resend from the console. Each transaction shows its webhook attempts, with the status code your server returned, and a resend button.
Checklist
- HTTPS endpoint, reachable from the internet.
- Signature verified on the raw body, with a five-minute timestamp window.
- Event ids stored; duplicates acknowledged and ignored.
- 2xx answer within 10 seconds.
- Latest event per
client_transaction_idwins. - A polling fallback and a daily reconciliation.