Every webhook tutorial covers the happy path: verify the signature, return 200, done.
Almost none cover what happens when your endpoint is down for a day.
Most payment platforms retry with exponential back-off, and then they stop. On Atoa's side the documented behaviour is: any response other than HTTP 200 is a failure, we retry with back-off, and if deliveries keep failing for more than 24 hours we stop and email the business owner with the failing URL. That is a sensible design. It also means there is a cliff, and anything that falls off it is only recoverable if you built a second path.
This is the setup I'd build so a bad Friday deploy never turns into a Monday reconciliation fire.
The mental model: a webhook is a hint
Treat the webhook as a doorbell, not the delivery. It tells you "something changed, go and look." The source of truth is the payment record, which you can always read back from the API.
Once you accept that, three design decisions follow.
1. Verify against the raw body, and do it first
Atoa's V2 signing sends X-Atoa-Signature: v1=<hex>, an HMAC-SHA256 over the entire raw request body. The secret looks like whsec_<base64>, and the key is the base64-decoded part after the prefix.
const crypto = require('crypto');
function isValidAtoaSignature(signingSecret, rawBody, headerValue) {
if (!headerValue || !headerValue.startsWith('v1=')) return false;
const provided = Buffer.from(headerValue.slice(3), 'hex');
const key = Buffer.from(signingSecret.slice(signingSecret.indexOf('_') + 1), 'base64');
const expected = crypto.createHmac('sha256', key).update(rawBody, 'utf8').digest();
// timingSafeEqual throws on unequal lengths
if (provided.length !== expected.length) return false;
return crypto.timingSafeEqual(provided, expected);
}
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8');
if (!isValidAtoaSignature(process.env.ATOA_SIGNING_SECRET, raw, req.get('X-Atoa-Signature'))) {
return res.status(401).send('Invalid signature');
}
queue.add('atoa-event', JSON.parse(raw)); // enqueue, don't process
res.status(200).send('OK'); // ack fast
});
Two things that bite people. Use express.raw, not express.json, because a re-serialised body will not match the signature. And note that the docs describe no timestamp in the header, so there is no built-in replay protection. Do not assume it. You need your own dedupe.
2. Make the handler idempotent on the payment, not the delivery
The payload carries paymentRequestId, orderId, paymentIdempotencyId and a status of COMPLETED, PENDING, FAILED (plus AUTHORIZED and CANCELLED for card). Retries mean you will see the same event more than once, and a PENDING can arrive after you have already acted on a COMPLETED if you are unlucky with ordering.
So I'd key state changes on paymentRequestId and only allow forward transitions:
const RANK = { PENDING: 1, FAILED: 2, COMPLETED: 2 };
async function apply(event) {
const row = await db.payments.get(event.paymentRequestId);
if (row && RANK[event.status] <= RANK[row.status]) return; // stale or duplicate
await db.payments.upsert({ id: event.paymentRequestId, status: event.status });
if (event.status === 'COMPLETED') await fulfilOrder(event.orderId);
}
A late PENDING can never un-complete an order, and a second COMPLETED is a no-op.
3. Add the sweep, the part most teams skip
This is the piece that makes the cliff irrelevant. Run a job every 10 to 15 minutes that finds payments you created more than N minutes ago and still hold as non-final, then asks the API for their current status and feeds the answer through the same apply() function.
cron('*/10 * * * *', async () => {
const stale = await db.payments.where({ status: 'PENDING' }).olderThan('10 minutes');
for (const p of stale) {
const live = await atoa.getPaymentStatus(p.id); // see docs.atoa.me, Get payment status
await apply(live);
}
});
The sweep and the webhook share one code path, so there is nothing new to test and no second definition of "paid." If your endpoint is down for 30 hours, the webhooks stop at 24, and the sweep quietly repairs everything on the next run after you are back.
One caveat: getPaymentStatus here is a thin wrapper I've sketched. Check the exact endpoint and fields in the docs before you copy it.
Setup details worth knowing
- V2 endpoints support multiple event types per endpoint and up to 3 endpoints per event type, each delivered independently. Point one at your app and one at a separate audit or analytics consumer, so one failing does not affect the other.
-
Failure tracking on V2 exposes
failureCountandlastDeliveredAt. Alert onlastDeliveredAtgoing stale rather than waiting for the owner email. - Optional endpoint auth (OAuth 2.0 or Basic Auth) can sit on top of the signature. The docs recommend combining them.
-
Test in sandbox with the trigger-test-webhook API, overriding
status,amountandorderIdto simulate failures. It is sandbox-only; production keys are rejected. - Rotation is instant. Rotating the signing secret invalidates the old one immediately, and enabling V2 switches every delivery, including the WooCommerce and Magento plugins. Update your env first.
What I'd do, in order
- Verify on the raw body, enqueue, return 200.
- Key state on
paymentRequestId, forward-only transitions. - Ship the sweep before you ship to production, not after the first incident.
- Alert on
lastDeliveredAt.
Webhooks are fast. Reconciliation is correct. You want both.
Try Atoa's sandbox: docs.atoa.me
Top comments (7)
Good to see the sweep framed as the part that makes the cliff irrelevant. One thing I'd add to the alerting: besides
lastDeliveredAt, watch the age of the oldest payment that is still non-final.lastDeliveredAttells you the webhook path stopped; it doesn't tell you the sweep is running but getting nothing useful back, for example when the same incident takes out the status endpoint, or a payment keeps coming back PENDING. The oldest-pending age catches both paths failing at once, and it gives you a clean rule for when a payment stops being the sweep's problem and becomes a person's: past a deadline, it goes to a review queue instead of being polled forever.Right, and the distinction is worth naming. lastDeliveredAt is a liveness signal about my delivery path. Oldest non-final age is a signal about the work. The first can be green while the second is terrible, and the case you give is the one that does it: a single incident takes out the webhook and the status endpoint, the sweep runs on schedule and learns nothing, and every dashboard says the system is up.
It's also the only one of these signals that can't be made green by restarting something. It's a function of payment state rather than of my infrastructure, which is a property worth having in an alert.
Your deadline rule is where this meets @ilragioniere's per-payment attempt cap further up the thread. The cap tells the sweep when to stop asking. The deadline tells it where to put the row. A cap on its own just drops the payment quietly, which is the cliff again at a smaller scale.
One concession about my own metric: the oldest-non-final threshold has a floor set by the slowest legitimate payment, so it's a number somebody tunes. lastDeliveredAt isn't. That doesn't make it worse, but it's the one that will drift.
That floor is why I'd set it per provider rather than as one number. In mobile top-ups I've seen one brand answer a status query in seconds and another only settle through a daily batch file. A single threshold is either deaf for the first or always firing for the second.
The rank guard fails open, and the payload you quote already tells you why.
The payload carries five statuses: COMPLETED, PENDING, FAILED, plus AUTHORIZED and CANCELLED for card. RANK maps three of them. So for a card event,
RANK[event.status] <= RANK[row.status]compares againstundefined, and in JS neitherundefined <= 2nor2 <= undefinedis true. The guard returns false and the write goes through. A stale PENDING walks a row backwards out of AUTHORIZED; a duplicate COMPLETED lands on top of a CANCELLED row. Failing open is the wrong direction for every one of those.Second, smaller, same map: FAILED and COMPLETED share rung 2, and the guard is
<=. Whichever arrives first wins and the other is dropped as a duplicate. That is only harmless as long as a payment can never move between those two states, which is an assumption about the provider's state machine that the code neither states nor checks. Give COMPLETED its own rung and the intent holds without the assumption.So: rank all five statuses explicitly, and make an unknown status an error rather than a pass. The
<=guard is fine; it is only as honest as the table behind it.The sweep filter compounds it.
where({ status: 'PENDING' })never re-reads a row sitting at FAILED or at AUTHORIZED, so the path that repairs a missed webhook is blind to exactly the rows where the webhook got a status the map can't rank. Select every row that isn't COMPLETED and is older than N minutes, with a per-payment attempt cap.One from your own quote of the docs: "any response other than HTTP 200 is a failure" means a 202 Accepted, the ordinary async ack, counts as a failed delivery and starts the back-off. Your handler returns 200 explicitly, which is right; keep it explicit if anyone ever moves the ack off the request path.
All four land, and the first two are the same bug wearing two hats. RANK is a total order over a state machine that isn't one. PENDING to AUTHORIZED to COMPLETED is a line. PENDING to CANCELLED and PENDING to FAILED branch off it, and COMPLETED and CANCELLED are both terminal with neither superseding the other. One integer per status cannot express that, so whatever integer I pick is wrong somewhere. FAILED and COMPLETED sharing rung 2 isn't a typo in the table. It's the table being the wrong shape. What that code wanted was an allowed-transitions set keyed on from and to, with everything not in it rejected. That also kills the undefined case for free, because an unknown status is not in the set.
On making unknown an error: yes, with one more turn. The event is already on a queue by then, so a bare throw retries a status nobody will ever add to the map, forever. It has to dead-letter and alert. Otherwise fail-closed becomes a quiet backlog, which is the cliff again with better manners.
The sweep filter is the one that stings. The article sells the sweep as the thing that makes the cliff irrelevant and then filters it to PENDING, so it is blind in exactly the place the rank table already failed. Not-final, not PENDING, with the per-payment cap you mention.
And the 202 is my own quoted sentence contradicting my own code sample, which I did not notice. If any response other than 200 is a failure, the ordinary async ack starts the back-off. I wrote ack fast as a latency argument. The actual argument is that the literal 200 is load-bearing.
The read-then-upsert in apply() lets the sweep and the webhook worker fulfil the same order twice. Picture a two-hour outage ending: Atoa's retried COMPLETED events land in the queue while the first cron run picks up the same rows. Both paths read PENDING, both pass the rank check, and both call fulfilOrder. A second COMPLETED is only a no-op when the two arrive one after the other.
I'd let the database decide. Do the transition as one conditional update, UPDATE payments SET status = 'COMPLETED' WHERE id = $1 AND status = 'PENDING' RETURNING id, and fulfil only when it returns a row. Whichever path loses the race gets zero rows and stops. If fulfilOrder lives in another service, a unique constraint on orderId in its table covers the same gap from the other side.
You're right, and the window is wider than the outage case. Any two deliveries of the same event landing in two workers hits it; the outage just makes it likely enough to notice. Read, decide, write, with the decision made from a value that can change between the read and the write.
Two things I'd change from your version. The hard-coded status = 'PENDING' is the right move taken one step short: once the guard lives in the WHERE clause, the allowed predecessors have to live there too, so it becomes AND status = ANY($2). And the row may not exist yet, which my upsert was quietly handling, so the real shape is an INSERT with ON CONFLICT DO UPDATE and the condition on the update. RETURNING still only fires when the update actually ran.
The constraint on orderId is the half I'd have missed, and it's the more important one. The conditional update makes the transition atomic. It does nothing about fulfilOrder running twice from two services that each legitimately won their own race. I collapsed a state-transition problem and a side-effect-idempotency problem into one function and only guarded the first.
@ilragioniere's reply below is the other end of the same code being wrong.