WalletD developers

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:

  1. Parse t and v1.
  2. Reject if t is 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.
  3. Recompute HMAC-SHA256(secret, t + "." + body) over the raw bytes you received.
  4. 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

EventFires when
topup.succeededMoney arrived and the wallet was credited
topup.failedThe processor declined or cancelled
topup.amount_mismatchThe processor confirmed a different amount or currency than the intent; nothing was credited and an operator must resolve it
transfer.completedA P2P transfer settled
payment.authorizedA hold was placed
payment.capturedA payment was captured, fully or partly
payment.voidedAn authorization was released
payment.expiredA hold aged out and was released
refund.completedA refund settled
cashback.grantedA cashback rule paid out
points.accruedPoints were earned
points.convertedPoints became cash
subscription.chargedA billing period was charged
subscription.retry_scheduledA charge failed and dunning began
subscription.pausedDunning gave up, or someone paused it
subscription.resumedA paused subscription restarted
subscription.canceledA subscription ended
credit_limit.changedA credit line was set or changed
fee_plan.setThe tenant fee plan changed
product.createdA merchant added a product to its catalog
product.updatedA product changed (details, price, status published)
product.archivedA product was retired from the catalog
order.createdA shopper reserved a cart; its stock is held until paid, canceled or expired
order.paidThe cart was paid with one payment and its stock sold
order.canceledThe shopper or the merchant released an open cart
order.expiredA cart nobody paid for aged out and its stock was released
inventory.low_stockA tracked variant fell to or under its low-stock threshold on a sale
import.completedA 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

  • Top-ups, the flow that most depends on webhooks.
  • Errors for what to return when you cannot process one.

On this page