# Webhooks

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

```bash
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"]}'
```

```json
{
  "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"
}
```

> **Warning**
>
> **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):

```bash
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

```http
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**.

> **Warning**
>
> **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

### curl

Not a real receiver, but useful for checking a secret by hand:

```bash
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"
```

### Go

```go
package webhooks

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"io"
	"net/http"
	"strconv"
	"strings"
	"time"

 "github.com/walletd-io/walletd-go"
)

const tolerance = 5 * time.Minute

// Verify reports whether the header matches the body under the secret.
func Verify(secret, header string, body []byte, now time.Time) bool {
	parts := strings.Split(header, ",")
	if len(parts) != 2 || !strings.HasPrefix(parts[0], "t=") || !strings.HasPrefix(parts[1], "v1=") {
		return false
	}

	seconds, err := strconv.ParseInt(parts[0][2:], 10, 64)
	if err != nil {
		return false
	}
	signedAt := time.Unix(seconds, 0)
	if signedAt.Before(now.Add(-tolerance)) || signedAt.After(now.Add(tolerance)) {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	fmt.Fprintf(mac, "%d.%s", seconds, body)
	expected := hex.EncodeToString(mac.Sum(nil))

	// Constant time: a byte-by-byte compare leaks the signature.
	return hmac.Equal([]byte(expected), []byte(parts[1][3:]))
}

// enqueue must commit to durable storage before returning nil.
func Handler(secret string, enqueue func(tenantID, eventID string, body []byte) error) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// The raw bytes are what was signed. Do not decode and re-encode.
		body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
		if err != nil {
			w.WriteHeader(http.StatusBadRequest)
			return
		}
		if !Verify(secret, r.Header.Get("X-Wallet-Signature"), body, time.Now()) {
			w.WriteHeader(http.StatusUnauthorized)
			return
		}

        event, err := walletd.ParseEvent(body)
        if err != nil {
            w.WriteHeader(http.StatusBadRequest)
            return
        }
        if err := enqueue(event.TenantID.String(), event.ID.String(), body); err != nil {
            w.WriteHeader(http.StatusServiceUnavailable)
            return
        }
		w.WriteHeader(http.StatusOK)
	}
}
```

### Python

```python
import hashlib
import hmac
import time
import json
from uuid import UUID

TOLERANCE_SECONDS = 300

def verify(secret: str, header: str, body: bytes, now: float | None = None) -> bool:
    now = time.time() if now is None else now
    try:
        timestamp_part, signature_part = header.split(",")
        timestamp = int(timestamp_part.removeprefix("t="))
        received = signature_part.removeprefix("v1=")
    except (ValueError, AttributeError):
        return False

    if abs(now - timestamp) > TOLERANCE_SECONDS:
        return False

    expected = hmac.new(
        secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
    ).hexdigest()
    # Constant time: a byte-by-byte compare leaks the signature.
    return hmac.compare_digest(expected, received)

# Flask. request.data is the raw body; request.json would re-serialise it.
@app.post("/webhooks/walletd")
def walletd_webhook():
    if not verify(WEBHOOK_SECRET, request.headers.get("X-Wallet-Signature", ""), request.data):
        return "", 401
    try:
        event = json.loads(request.data)
        tenant_id, event_id = UUID(event["tenant_id"]), UUID(event["id"])
        if not tenant_id.int or not event_id.int or not isinstance(event["type"], str) or not event["type"]:
            return "", 400
    except (ValueError, KeyError, TypeError, AttributeError):
        return "", 400
    try:
        enqueue(str(tenant_id), str(event_id), request.data)  # durable commit
    except Exception:
        return "", 503
    return "", 200
```

### PHP

```php
<?php

const WALLETD_TOLERANCE = 300;

function walletd_verify(string $secret, string $header, string $body): bool
{
    $parts = explode(',', $header);
    if (count($parts) !== 2 || !str_starts_with($parts[0], 't=') || !str_starts_with($parts[1], 'v1=')) {
        return false;
    }

    $timestamp = (int) substr($parts[0], 2);
    $received = substr($parts[1], 3);
    if (abs(time() - $timestamp) > WALLETD_TOLERANCE) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);
    // Constant time: a byte-by-byte compare leaks the signature.
    return hash_equals($expected, $received);
}

// php://input is the raw body. Reading $_POST would not give you the bytes signed.
$body = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_WALLET_SIGNATURE'] ?? '';

if (!walletd_verify($webhookSecret, $header, $body)) {
    http_response_code(401);
    exit;
}

try {
    $event = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
    foreach (['tenant_id', 'id'] as $field) {
        $value = $event[$field] ?? null;
        if (!is_string($value) || !preg_match('/^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i', $value)
            || $value === '00000000-0000-0000-0000-000000000000') {
            http_response_code(400); exit;
        }
    }
    if (!is_string($event['type'] ?? null) || $event['type'] === '') {
        http_response_code(400); exit;
    }
} catch (JsonException $e) {
    http_response_code(400); exit;
}
try {
    enqueue(strtolower($event['tenant_id']), strtolower($event['id']), $body); // durable commit
} catch (Throwable $e) {
    http_response_code(503); exit;
}
http_response_code(200);
```

### JavaScript

```javascript
import crypto from "node:crypto";
import express from "express";

const TOLERANCE_SECONDS = 300;

export function verify(secret, header, body, now = Date.now() / 1000) {
  const parts = String(header).split(",");
  if (parts.length !== 2 || !parts[0].startsWith("t=") || !parts[1].startsWith("v1=")) return false;

  const timestamp = Number(parts[0].slice(2));
  const received = parts[1].slice(3);
  if (!Number.isFinite(timestamp) || Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.`)
    .update(body)
    .digest("hex");

  // timingSafeEqual throws on a length mismatch, so check that first.
  if (expected.length !== received.length) return false;
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received));
}

const app = express();

// express.raw, not express.json: the raw bytes are what was signed.
app.post("/webhooks/walletd", express.raw({ type: "application/json" }), async (req, res) => {
  if (!verify(process.env.WALLETD_WEBHOOK_SECRET, req.get("X-Wallet-Signature"), req.body)) {
    return res.sendStatus(401);
  }
  let event;
  const uuid = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
  const validID = value => typeof value === "string" && uuid.test(value)
    && value !== "00000000-0000-0000-0000-000000000000";
  try {
    event = JSON.parse(req.body.toString("utf8"));
    if (!validID(event?.id) || !validID(event?.tenant_id)
        || typeof event?.type !== "string" || !event.type) return res.sendStatus(400);
  } catch { return res.sendStatus(400); }
  try {
    await enqueue(event.tenant_id.toLowerCase(), event.id.toLowerCase(), req.body);
  } catch { return res.sendStatus(503); }
  res.sendStatus(200);
});
```

### Java

```java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;

public final class WalletDWebhooks {
    private static final long TOLERANCE_SECONDS = 300;

    public static boolean verify(String secret, String header, byte[] body, Instant now) {
        if (header == null) return false;
        String[] parts = header.split(",");
        if (parts.length != 2 || !parts[0].startsWith("t=") || !parts[1].startsWith("v1=")) return false;

        long timestamp;
        try {
            timestamp = Long.parseLong(parts[0].substring(2));
        } catch (NumberFormatException e) {
            return false;
        }
        if (Math.abs(now.getEpochSecond() - timestamp) > TOLERANCE_SECONDS) return false;

        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
            mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8));
            mac.update(body);
            String expected = HexFormat.of().formatHex(mac.doFinal());

            // Constant time: a byte-by-byte compare leaks the signature.
            return MessageDigest.isEqual(
                    expected.getBytes(StandardCharsets.UTF_8),
                    parts[1].substring(3).getBytes(StandardCharsets.UTF_8));
        } catch (Exception e) {
            return false;
        }
    }
}
```

```java
// Spring: take the body as byte[] so nothing re-serialises it.
@PostMapping(path = "/webhooks/walletd", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> receive(
        @RequestBody byte[] body,
        @RequestHeader("X-Wallet-Signature") String signature) {

    if (!WalletDWebhooks.verify(webhookSecret, signature, body, Instant.now())) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }
    // objectMapper is the application's Jackson ObjectMapper.
    java.util.UUID tenantId, eventId;
    try {
        var event = objectMapper.readTree(body);
        tenantId = java.util.UUID.fromString(event.required("tenant_id").asText());
        eventId = java.util.UUID.fromString(event.required("id").asText());
        if (tenantId.equals(new java.util.UUID(0, 0)) || eventId.equals(new java.util.UUID(0, 0))
                || !event.required("type").isTextual() || event.get("type").asText().isEmpty()) {
            return ResponseEntity.badRequest().build();
        }
    } catch (Exception e) { return ResponseEntity.badRequest().build(); }
    try {
        enqueue(tenantId.toString(), eventId.toString(), body); // durable commit
    } catch (Exception e) { return ResponseEntity.status(503).build(); }
    return ResponseEntity.ok().build();
}
```

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

> **Warning**
>
> **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:

```bash
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:

```bash
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

- [Top-ups](/guides/topups/), the flow that most depends on webhooks.
- [Errors](/errors/) for what to return when you cannot process one.
