How do you handle webhooks for stablecoin settlement?
Settlement webhooks arrive twice, out of order, and from anyone who finds your URL. What to verify, which event means the money landed, and how to stop a double credit.

The short answer
A settlement webhook is your provider telling your server that money moved, and three things break it: duplicate delivery, out-of-order arrival, and a payload you haven't verified. The Daya API signs every webhook with HMAC-SHA256 in an X-Daya-Signature header, delivers at least once, and guarantees no ordering at all.
This is the part of an integration that costs real money when it's wrong, because the failure mode is paying a customer twice. The guide to stablecoin on-ramps and off-ramps for developers in Africa covers choosing a provider and the rest of the build. This piece is only about what happens after the provider calls you back.
What does a settlement webhook actually tell you?
A settlement webhook is an HTTP request your provider sends to a URL you own, saying that something changed on a deposit, a transfer, a withdrawal, a funding account or a customer. The Daya API's webhooks use one envelope for all of them, with four fields: the event type, an id for that delivery, a data object carrying the resource, and a timestamp.
{
"event": "transfer.completed",
"id": "evt_01HQ...",
"timestamp": "2026-03-10T09:03:00Z",
"data": {
"reference": "txn_ngn_001",
"status": "SETTLED",
"rail": "NGN_BANK"
}
}Each family of events has its own terminal events, meaning the ones after which nothing else will happen to that resource. Those are the events your ledger should act on.
| Event family | What it covers | Terminal events |
|---|---|---|
| Deposit | Money arriving, on-chain or into a dollar or euro virtual account | deposit.completed, deposit.failed, deposit.reversed |
| Transfer | Money going out on a rail, such as a naira bank payout | transfer.completed, transfer.failed, transfer.reversed |
| Withdrawal | A withdrawal from your own merchant balance | withdrawal.completed, withdrawal.failed |
| Funding account | The account a customer pays into | funding_account.failed, funding_account.disabled |
| Bridge | A temporary deposit address with an expiry | bridge.deposit.completed, bridge.deposit.failed, bridge.deposit.reversed |
| Customer verification | Where a customer has reached in KYC | customer.verification.approved, customer.verification.rejected |
One detail to subscribe carefully to: leaving the event list empty, or setting it to an empty array, subscribes you to everything, including the bridge events. Naming events explicitly gets you only those, and an unknown name is rejected rather than ignored.
How do you verify that a webhook came from your provider?
Verify the signature before you read the payload, because anyone who finds your webhook URL can post to it. The Daya API sends an HMAC-SHA256 digest, hex encoded, in an X-Daya-Signature header, keyed with the webhook secret from your Daya dashboard. Daya's signature verification page sets out the four steps.
- Take the raw request body, exactly the bytes that arrived.
- Compute an HMAC-SHA256 digest over those bytes with your webhook secret.
- Compare your hex digest with the header value using a timing-safe comparison, crypto.timingSafeEqual in Node or hmac.compare_digest in Python.
- Only then parse the JSON.
Step one is the one that catches people. Parsing the body and re-serialising it changes whitespace and key order, so the digest you compute won't match the one that was sent, and every webhook looks forged. In Express that means reading the webhook route with express.raw rather than express.json.
const crypto = require('crypto')
app.post(
'/webhooks/daya',
express.raw({ type: 'application/json' }),
(req, res) => {
const sent = req.get('X-Daya-Signature') || ''
const expected = crypto
.createHmac('sha256', process.env.DAYA_WEBHOOK_SECRET)
.update(req.body) // the raw Buffer, never a parsed object
.digest('hex')
const a = Buffer.from(sent, 'utf8')
const b = Buffer.from(expected, 'utf8')
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.sendStatus(401)
}
const event = JSON.parse(req.body.toString('utf8'))
res.sendStatus(200) // answer first
queue.add(event) // do the work afterwards
}
)The Daya signature covers the body and nothing else, so there's no timestamp inside the signed string and no documented window after which a replayed request is refused. Storing every event id and rejecting one you've already handled closes that gap, and you need it anyway for the next section.
Which event means the money has actually arrived?
Credit a customer on deposit.completed and on nothing earlier. deposit.received means the Daya API has seen the deposit, not that it has settled, and the events page says plainly not to credit on it. The terminal deposit events are deposit.completed, deposit.failed and deposit.reversed, and a transfer's are transfer.completed, transfer.failed and transfer.reversed.
Watch the difference between an event name and a status value, because they aren't the same thing. A transfer.completed event carries "status": "SETTLED" in its data, so a handler that branches on the status string rather than the event name will sit there waiting for a status of COMPLETED that never comes.
Failure reasons are only in some places, which is worth knowing before you build a support screen around them. Bank account verification failures carry failure_code and failure_message, bridge deposit payouts carry the same pair, customer verification rejections carry rejection_reasons, and bridge deposits under review carry flag_code and flag_message. A failed deposit or a failed transfer carries no documented reason field, so plan to refetch the resource rather than read one off the event.
Why do webhooks arrive twice, and how do you stop a double credit?
Delivery is at-least-once, so a duplicate isn't a fault, it's the contract. The Daya API retries with backoff whenever your endpoint answers with anything other than a 2xx or times out, which means a slow handler gets more duplicates rather than fewer. Store every event id you've processed and drop the ones you've seen before.
Dollar and euro virtual account deposits break the obvious version of that, and this is the trap worth reading twice. A virtual account deposit emits deposit.received already carrying "status": "COMPLETED", then emits deposit.completed for the same deposit. The two events carry different envelope ids, so deduplicating on the envelope id lets both through, and a handler that credits on any event with a COMPLETED status credits that deposit twice.
The fix is to key on the right field for the right kind of deposit.
- Ordinary deposits, transfers and withdrawals: deduplicate on the envelope id.
- Dollar and euro virtual account deposits: deduplicate on data.id, because the envelope id differs between the two events for one deposit.
- Bank account verification results: keep the highest identity_version, and where two share a version, the later timestamp.
create table processed_webhook_events (
dedupe_key text primary key,
event text not null,
processed_at timestamptz not null default now()
);Insert the key inside the same database transaction that moves the balance, not before it and not after. If the insert hits the primary key, you have already handled that event and there's nothing to do. If the balance update fails, the key rolls back with it and the retry gets a clean run.
One more thing about dollar deposits, because it produces a confusing 404. A GET on /v1/deposits/{id} returns 404 for a deposit whose type is USD_DEPOSIT, and those are refetched from /v1/virtual-account-deposits/{id} instead. So read data.type before you call back for the current state.
What do you do when events arrive out of order?
Don't rely on arrival order, because the Daya API doesn't guarantee one. Reconcile against the resource's current state rather than against the sequence you received, and use the envelope timestamp when you genuinely need to sort. A terminal event is final, so a non-terminal event that turns up for a resource you've already settled is stale and should be dropped.
- Treat the event as a prompt to look, not as the truth. Refetch the resource and act on what it says now.
- Stop showing a bridge's payment instructions at its expires_at rather than waiting for bridge.expired, which can arrive after the address has already stopped working.
- Use a funding account instruction only while its own status is ACTIVE, rather than assuming the account-level status covers it.
Answer 200 straight away and do the work on a queue. A handler that settles a ledger, writes an audit row and emails a customer before replying is the handler that times out, gets retried, and produces exactly the duplicate it was trying to avoid.
Where does the Daya API fit?
The Daya API documents the whole webhook contract this piece describes: the event list, the X-Daya-Signature header and its HMAC-SHA256 digest, at-least-once delivery, the absence of an ordering guarantee, and the deduplication rule that differs for dollar and euro virtual account deposits. It settles USDC and USDT straight to a blockchain address on its supported chains, and the deposit, transfer and withdrawal events are how your server learns each leg finished.
If you're wiring up the whole path rather than just the callbacks, the legs either side have their own pieces. Accepting naira and settling in USDC or USDT covers the collection side, and paying out naira to a Nigerian bank account with an API covers the payout side and what that payout costs.
How do you test this before real money moves?
Use the sandbox, and test the ugly cases rather than the happy one. The Daya API's sandbox creates a deposit against a test funding account and fires the same event sequence production will, so every case below is reproducible before a customer is involved. Withdrawal flows need the merchant balance funded first.
- The duplicate. Deliver the same event twice and check the balance moves once.
- The pair. A virtual account deposit.received carrying a COMPLETED status, then deposit.completed for the same deposit. Check you credited once.
- The reversal. A deposit.reversed after a deposit.completed you have already credited.
- The forgery. Post a body with no signature, and one with a wrong signature. Both should get a 401 and change nothing.
- The slow handler. Hold the response long enough to be retried, and check the retry doesn't double-credit.
- The wrong order. Deliver the terminal event first and the non-terminal one after it, and check the stale one is dropped.
Keep the sandbox and production secrets separate, and rotate them. Use HTTPS in production, which the documentation is explicit about: plain HTTP is for local and sandbox work only.
Frequently asked questions
- How do you handle webhooks for stablecoin settlement?
Verify the signature over the raw request body, answer 200 immediately, and do the work on a queue. Credit a customer only on a terminal event, which for the Daya API means deposit.completed rather than deposit.received. Store every event id you have processed, in the same database transaction that moves the balance, so a retried delivery can't credit twice.
- How do you verify a Daya API webhook signature?
Compute an HMAC-SHA256 digest over the raw request body using your webhook secret, hex encode it, and compare it with the X-Daya-Signature header using a timing-safe comparison. Do it before parsing the JSON, because parsing and re-serialising changes whitespace and key order and the digest then never matches.
- Which webhook event means a stablecoin deposit has settled?
deposit.completed. The Daya API's documentation says not to credit on deposit.received, which only means the deposit has been seen. The terminal deposit events are deposit.completed, deposit.failed and deposit.reversed, and a reversal can follow a completion, so a credit has to be reversible.
- How do you stop a webhook crediting a user twice?
Keep a table of processed event ids with the id as the primary key, and insert the row in the same transaction as the balance change. On the Daya API, deduplicate ordinary deposits, transfers and withdrawals on the envelope id, and dollar or euro virtual account deposits on data.id, because those emit two events with different envelope ids for one deposit.
- Do webhook events arrive in the order they happened?
No. The Daya API gives no ordering guarantee, so a later event can arrive first. Reconcile against the resource's current state rather than the order you received things in, treat a terminal event as final, and use the envelope timestamp when you need to sort. For bank account verification, prefer the highest identity_version.
- What should a webhook endpoint return, and how quickly?
Return any 2xx status, and return it fast. The Daya API retries with backoff on a non-2xx response or a timeout, so slow handlers produce more duplicate deliveries, not fewer. Acknowledge first and move the ledger write, the audit row and any email onto a queue.





