Webhooks
Signed, at-least-once deliveries that tell your backend money moved, and how to verify one in curl, Go, Python, PHP, JavaScript and Java.
Webhooks tell your backend that money moved, without you polling for it. Every delivery is HMAC-signed, and verifying the signature is not optional: your endpoint is a public URL, and anyone can POST JSON at it.
Register an endpoint
curl -sS -X POST "$WALLETD_API/v1/webhook_endpoints" \
-H "Authorization: Bearer $WALLETD_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://api.yourapp.com/webhooks/walletd", "event_filters": ["topup.succeeded", "transfer.completed"]}'{
"id": "019fee0d-2c3d-7e4f-9a5b-6c7d8e9f0a1b",
"url": "https://api.yourapp.com/webhooks/walletd",
"event_filters": ["topup.succeeded", "transfer.completed"],
"status": "active",
"secret": "whsec_9f2c4a...",
"created_at": "2026-08-11T05:20:11.004Z"
}The secret is shown exactly once. Store it in your secret manager now.
There is no endpoint that will show it to you again. Omit event_filters to
receive everything.
One secret per endpoint, and an endpoint belongs to one tenant. If your backend receives for several tenants, select the secret by the delivery's tenant_id; verifying tenant A's delivery against tenant B's secret fails exactly like a forgery.
Merchant endpoints
A merchant integrating its own shop, till or sync job registers endpoints for its business rather than for the whole loop. These live under the merchant's own route and accept a merchant-bound API key (see the commerce guide):
curl -sS -X POST "$WALLETD_API/v1/clients/$CLIENT_ID/webhook_endpoints" \
-H "Authorization: Bearer $MERCHANT_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://api.yourshop.com/walletd", "event_filters": ["order.paid", "inventory.low_stock"]}'A merchant endpoint is delivered only the events that belong to that business: product.*, order.*, inventory.* and import.*. Payments, transfers, top-ups and every other tenant-wide event never reach it, whatever its filters say. The tenant's own endpoints keep receiving everything, merchants' events included, and each merchant event carries a client_id in its envelope so a tenant receiver can route it. GET lists the business's endpoints, DELETE .../webhook_endpoints/{endpointId} stops deliveries, and GET .../webhook_deliveries shows the attempts made to them. The portal's Developers page does the same three things.
What a delivery looks like
POST /webhooks/walletd HTTP/1.1
Content-Type: application/json
X-Wallet-Signature: t=1786405949,v1=d0da4da96bafdcfd2bff5b2b68516d0b45c72d039203deb250ed2c25f91af9a2
X-Wallet-Event-Id: 019fedda-88ef-7253-8253-14d9e24723fb
{
"id": "019fedda-88ef-7253-8253-14d9e24723fb",
"type": "topup.succeeded",
"tenant_id": "019fedcf-70a4-7ecd-bda1-f45dd0fdc0ca",
"created_at": "2026-08-11T05:12:29.922Z",
"data": {
"topup": {
"id": "019fedda-88e6-7453-b2ef-33b30424910e",
"user_id": "019fedcf-7183-7a57-82e7-3ca266429b04",
"amount": 5000,
"status": "succeeded"
}
}
}A delivery about a merchant's catalog, stock or orders also carries "client_id" in the envelope, next to tenant_id.
The signature scheme
X-Wallet-Signature: t=<unix seconds>,v1=<hex hmac_sha256(secret, "<t>.<raw body>")>To verify:
- Parse
tandv1. - Reject if
tis further from your clock than the tolerance you chose, in either direction. Five minutes is the value we recommend and the one every sample below uses. - Recompute
HMAC-SHA256(secret, t + "." + body)over the raw bytes you received. - Compare in constant time.
The replay window is yours, not ours. WalletD enforces no tolerance of its
own: it signs with the current time on every attempt and delivers. Nothing
rejects an old signature unless your receiver does, so you must reject
deliveries outside your tolerance yourself. Because each retry and each manual
redelivery is signed afresh, a tight tolerance costs you nothing: an event
redelivered a week later still arrives with a current t.
Two things that break verification and are easy to miss: signing a re-serialised body instead of the raw bytes (key order changes, and the hash changes with it), and comparing with == (a timing side channel that leaks the signature byte by byte).
Verifying, in each language
Not a real receiver, but useful for checking a secret by hand:
BODY=$(cat delivery.json)
TS=$(printf '%s' "$SIG_HEADER" | sed 's/t=\([0-9]*\).*/\1/')
EXPECTED=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" -hex | sed 's/.* //')
printf '%s' "$SIG_HEADER" | grep -q "v1=$EXPECTED" && echo verified || echo "NOT verified"The receiver snippets require an application-provided enqueue(tenantID, eventID, rawBody) that commits a durable inbox row under a unique (tenant_id, event_id)
constraint. An existing row is a successful no-op. These snippets do not provide a
queue or database implementation. For Go, ParseEvent comes from the Go SDK.
A worker must commit its processed marker and local business changes in the same database transaction. Roll back both on failure. For effects in another system, commit an outbox intent with the marker, then deliver it using a stable downstream idempotency key; a local database transaction cannot make a remote effect atomic. Test concurrent duplicates, altered advisory headers, failure before inbox commit, and worker crashes before and after commit. A deduplication map in a unit test does not establish durable crash recovery.
What a correct receiver does
Verify first. Before parsing, before logging the contents, before anything. An unverified body is attacker-controlled input, and your endpoint is a public URL that anyone can POST JSON at.
Be idempotent. Delivery is at-least-once. Retries after your own timeout, redeliveries, and replays all mean you will see the same event twice. After signature verification, validate the envelope and deduplicate on its signed
(tenant_id, id). The HTTP X-Wallet-Event-Id header is unsigned and advisory:
changing it must never create new work. If an endpoint is configured for one
tenant, also require the verified tenant_id to match that configuration.
Answer fast, after persistence. Return 2xx only after durable inbox acceptance and do the work asynchronously. An in-process goroutine is not durable storage. Return 503 if acceptance fails. The delivery timeout is ten seconds, and a slow endpoint burns its own retry schedule.
Tolerate unknown types. New event types get added. An unfamiliar type should be ignored, not crash your handler.
Never trust the payload as an instruction. An event is an observation of something that already happened. If you need current state, read it back from the API.
Retries and redelivery
Non-2xx responses retry with backoff for roughly a day (12 attempts). Every attempt is recorded:
curl -sS "$WALLETD_API/v1/webhook_deliveries?limit=50" -H "Authorization: Bearer $WALLETD_API_KEY"If your endpoint was down past the retry window, redeliver by hand:
curl -sS -X POST "$WALLETD_API/v1/webhook_events/$EVENT_ID/redeliver" \
-H "Authorization: Bearer $WALLETD_API_KEY"Events commit with walletd's domain state and stored response in its local database. Ledger postings commit separately in ledgerd. A crash after a posting but before the walletd commit can delay the corresponding event until the money operation is repaired. Absence of a webhook is therefore not proof that a payment failed. Retry an uncertain originating request with its original idempotency key and reconcile its result.
Once the outbox event has committed, receiver downtime does not remove it. Delivery has a bounded retry budget; use the event and delivery APIs to investigate exhausted attempts and request redelivery. Store the event ID with your own processing result so redelivery cannot apply the business effect twice.
How long we keep it
Event history is retained for 30 days. That is the window for GET /v1/webhook_events, GET /v1/webhook_deliveries, and redelivery: past it, the event and its delivery attempts are removed and there is nothing left to redeliver.
Thirty days is generous for an outage and short for an archive. If you need event history beyond that, store it yourself as you receive it. The rule of thumb: treat our history as a recovery buffer, not as your system of record.
Your endpoint being slow will not slow your payments
Worth stating plainly, because on many platforms it is not true. Delivery runs on a worker pool of its own, separate from the pool that evaluates rewards, charges subscriptions, and expires authorizations. If your endpoint is slow, or down, deliveries to you queue up and retry, and nothing else about your integration changes: payments still capture at the same speed, cashback is still granted on time, subscriptions still charge.
We learned this the direct way. The two used to share one pool, and during a load test a receiver that ran out of memory took every worker with it: reward evaluation stopped for ten minutes while the platform reported itself healthy. Your endpoint can no longer do that to your own money paths, or to anyone else's.
Event types
| Event | Fires when |
|---|---|
topup.succeeded | Money arrived and the wallet was credited |
topup.failed | The processor declined or cancelled |
topup.amount_mismatch | The processor confirmed a different amount or currency than the intent; nothing was credited and an operator must resolve it |
transfer.completed | A P2P transfer settled |
payment.authorized | A hold was placed |
payment.captured | A payment was captured, fully or partly |
payment.voided | An authorization was released |
payment.expired | A hold aged out and was released |
refund.completed | A refund settled |
cashback.granted | A cashback rule paid out |
points.accrued | Points were earned |
points.converted | Points became cash |
subscription.charged | A billing period was charged |
subscription.retry_scheduled | A charge failed and dunning began |
subscription.paused | Dunning gave up, or someone paused it |
subscription.resumed | A paused subscription restarted |
subscription.canceled | A subscription ended |
credit_limit.changed | A credit line was set or changed |
fee_plan.set | The tenant fee plan changed |
product.created | A merchant added a product to its catalog |
product.updated | A product changed (details, price, status published) |
product.archived | A product was retired from the catalog |
order.created | A shopper reserved a cart; its stock is held until paid, canceled or expired |
order.paid | The cart was paid with one payment and its stock sold |
order.canceled | The shopper or the merchant released an open cart |
order.expired | A cart nobody paid for aged out and its stock was released |
inventory.low_stock | A tracked variant fell to or under its low-stock threshold on a sale |
import.completed | A bulk catalog import was committed; the payload lists what changed |
Testing your receiver
Point an endpoint at a local tunnel and run a real flow; a sandbox top-up produces a genuine signed delivery. To check the failure path, tamper with one byte of the body and confirm you return 401. A receiver that accepts a modified body is the bug this whole page exists to prevent.
Next
Products, stock, orders and bulk import
The merchant commerce surface: variants, cost-plus pricing, stock movements, cart orders, and importing a catalog from a spreadsheet.
SDKs and tools
The Go SDK, the three OpenAPI documents, an importable API collection, and the markdown endpoints coding agents read.