# Webhooks and signature verification

Register endpoints, verify signatures, retries, pause and secret rotation.

WireBloom notifies your HTTPS endpoint when something happens, so you do not have to poll. The payload schema of every event is in the [event catalogue](/events).

## Register an endpoint

```json
POST /v1/integrator-webhooks          (scope webhooks:manage)
{ "url": "https://erp.example.com/wirebloom/webhooks",
  "events": ["payment.status_changed", "incoming_payment.received"],
  "description": "ERP payments" }
```

The response of [`createIntegratorWebhook`](/reference/public-api#createIntegratorWebhook) contains `secret` (`whsec_…`, **shown once**) for verifying signatures. Endpoint rules: `https://` only, a public host name or address (no private, loopback, link-local, internal names, no credentials in the URL); every delivery re-checks the resolved address. Up to 10 endpoints per customer. [`testIntegratorWebhook`](/reference/public-api#testIntegratorWebhook) sends a signed test event right away (`data.test: true`, header `WireBloom-Test: true`) and returns the attempt.

## Events

| Event                                                                    | When                                                                          |
| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
| `payment.created`                                                        | a payment was submitted (left draft)                                          |
| `payment.status_changed`                                                 | any later status change (`previousStatus` → `status`)                         |
| `payment.completed` / `payment.failed` / `payment.returned`              | the corresponding terminal change (also sent as `payment.status_changed`)     |
| `transfer.completed`                                                     | an internal transfer between own balances completed                           |
| `incoming_payment.received`                                              | money arrived on a balance                                                    |
| `exchange.completed`                                                     | a currency exchange completed                                                 |
| `recipient.status_changed`                                               | a recipient was added or its approval status changed                          |
| `approval.requested` / `approval.decided`                                | a payment or recipient needs / got an approval decision                       |
| `funds_request.status_changed`                                           | a funds request changed status                                                |
| `recipient.verification_completed`, `statement.available`, `balance.low` | defined; delivery arrives with later releases (the sandbox can simulate them) |

## Payload and headers

Payloads are thin: ids, statuses and amounts. Fetch the full resource with the API (`GET /payments/{id}` …) when you need details.

```http
POST /wirebloom/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: WireBloom-Webhooks/1.0
WireBloom-Event-Id: 0192a6f3-0000-7000-8000-0000000e0001
WireBloom-Event-Type: payment.status_changed
WireBloom-Timestamp: 1790161320
WireBloom-Signature: v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

{"id":"0192a6f3-0000-7000-8000-0000000e0001","type":"payment.status_changed",
 "createdAt":"2026-09-23T11:02:00Z","apiVersion":"v1","tenantId":"0192a6f0-0000-7000-8000-0000000000c1",
 "data":{"object":"payment","id":"0192a6f1-3333-7000-8000-00000000c001","status":"completed",
         "previousStatus":"processing","amount":{"amountMinor":"100000","currency":"EUR"}}}
```

## Verify every delivery

`WireBloom-Signature` is `v1=` + hex HMAC-SHA256 of `WireBloom-Timestamp + "." + rawBody` with your endpoint secret. During a secret rotation it carries two entries (`v1=…, v1=…`); accept either.

```ts
import { createHmac, timingSafeEqual } from 'node:crypto';

export function verify(
  rawBody: Buffer,
  timestamp: string,
  signatures: string,
  secret: string,
): boolean {
  if (!/^\d+$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300)
    return false;
  const expected = createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest();
  return signatures
    .split(',')
    .map((s) => s.trim())
    .filter((s) => s.startsWith('v1='))
    .some((s) => {
      const candidate = Buffer.from(s.slice(3), 'hex');
      return candidate.length === expected.length && timingSafeEqual(candidate, expected);
    });
}
```

```python
import hashlib, hmac, time

def verify(raw_body: bytes, timestamp: str, signatures: str, secret: str) -> bool:
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300:
        return False
    expected = hmac.new(secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(s.strip()[3:], expected)
               for s in signatures.split(",") if s.strip().startswith("v1="))
```

Receiver rules:

1. Verify against the **raw** body bytes, before JSON parsing; compare in constant time.
2. Reject timestamps more than 5 minutes from your clock (replay protection).
3. De-duplicate on `WireBloom-Event-Id` (deliveries are at-least-once; an event id is the same for retries, redeliveries and every endpoint that receives it).
4. Answer `2xx` within 10 seconds, then process asynchronously. Redirects are not followed.
5. Do not rely on ordering: use `status` / `previousStatus` and re-read the resource.

## Retries, pause and recovery

Any non-`2xx`, timeout or connection error is retried after 1 min, 5 min, 30 min, 2 h, 6 h, then every 12 h, for up to 72 h after the event. After 20 consecutive failed attempts the endpoint is **paused**; after 72 h without a successful delivery it is **disabled**. Both notify the customer's admins. Paused or disabled endpoints receive no new events.

To recover: fix the receiver, [`updateIntegratorWebhook`](/reference/public-api#updateIntegratorWebhook) with `"enabled": true` (send `If-Match`), then list [`listWebhookDeliveries`](/reference/public-api#listWebhookDeliveries) with `filter[succeeded]=false` and redeliver what you missed ([`redeliverWebhook`](/reference/public-api#redeliverWebhook), same event id). You can also disable an endpoint yourself with `"enabled": false`.

## Secret rotation

[`rotateIntegratorWebhookSecret`](/reference/public-api#rotateIntegratorWebhookSecret) returns a new secret; for 24 h deliveries are signed with both the new and the old secret. Deploy the new secret within that window.
