Webhooks
We notify you of everything asynchronous with a POST to your webhookUrl.
The webhook is the source of truth for the outcome, not the
POST /payouts response.
Registering your webhook
Section titled “Registering your webhook”You do it yourself, with your own key. No need to write to us.
curl -X PUT $BASE/v1/tenant/webhook \ -H "x-api-key: $KEY" -H 'content-type: application/json' \ -d '{"url":"https://your-domain.com/webhooks/bolivar"}'And test it before moving money: we deliver a test event to you and return what your server answered.
curl -X POST $BASE/v1/tenant/webhook/test -H "x-api-key: $KEY"{ "url": "https://your-domain.com/webhooks/bolivar", "entregado": true, "statusCode": 200, "durationMs": 143, "error": null }url: null turns pushing off. You do not lose any event: they remain in
GET /v1/events, which is a
legitimate way to integrate.
Events
Section titled “Events”eventType |
When |
|---|---|
payment.settled |
The payout is final: the beneficiary has their Bs |
payment.failed |
The payout was not executed. The entry is already reversed |
payment.reversed |
The bank returned an already settled payout. The funds go back to your balance |
It is sent only once per payout.
The body
Section titled “The body”{ "id": "40545", "eventType": "payment.settled", "attempt": 1, "payload": { "paymentId": "89920401-26da-4f69-b57d-bc20e0366ce2", "asset": "BS", "amount": "5000000", "bankReference": "012345678901", "method": "pago_movil", "reference": "Nómina octubre 0007" } }payload.reference is the payment concept you sent when paying out (null
if you sent none). It is not bankReference.
| Field | What it is |
|---|---|
id |
Event id. Deduplicate on this |
eventType |
The type from the table above |
payload |
Details of what happened. See the note below |
attempt |
Delivery attempt number. 1 the first time |
Headers
Section titled “Headers”POST /webhooks/dispersion HTTP/1.1content-type: application/jsonx-signature: sha256=<HMAC-SHA256(your whsec, "<x-timestamp>.<raw body>")>x-timestamp: 1785270095123x-event-type: payment.settledx-event-id: 40545x-delivery-attempt: 1Verifying the signature
Section titled “Verifying the signature”const ts = req.headers['x-timestamp'];const raw = req.body; // the exact BYTES, not the re-serialized object
const expected = crypto.createHmac('sha256', WHSEC) .update(`${ts}.${raw}`) .digest('hex');
const signature = String(req.headers['x-signature']).replace('sha256=', '');const valid = crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
// Anti-replay: reject if the timestamp is more than 5 minutes oldconst fresh = Math.abs(Date.now() - Number(ts)) < 300_000;
if (!valid || !fresh) return res.sendStatus(401);| Algorithm | HMAC-SHA256 over "<x-timestamp>.<raw body>", in hexadecimal |
| Secret | One per program |
| Anti-replay window | ±5 minutes. Your receiver enforces it: we sign with the timestamp, you decide to reject old ones |
| Comparison | Constant time (timingSafeEqual), never with === |
Store it with the same care as the API key. You get or rotate it yourself with
POST /v1/tenant/webhook-secret/rotate: see test your
webhook.
Receiver rules
Section titled “Receiver rules”-
Verify the signature and freshness before looking at the body.
-
Deduplicate on
id. Delivery is at-least-once: the same event can arrive twice and your handler has to be idempotent. Theidalso travels inx-event-id. -
Respond
2xxfast and process in the background. Any response that is not 2xx counts as a failure, and so does a timeout: we cut off at 5 seconds. -
Read the full state with
GET /v1/payments/{id}before closing the operation in your accounting.
If your receiver goes down
Section titled “If your receiver goes down”We retry with increasing backoff, up to 12 attempts:
| Attempt | Wait since the previous one |
|---|---|
| 2 | 5 s |
| 3 | 15 s |
| 4 | 45 s |
| 5 | 2 min 15 s |
| 6 | 6 min 45 s |
| 7 | 20 min 15 s |
| 8 | ~1 h |
| 9 | ~3 h |
| 10 – 12 | 6 h |
Once the attempts run out, the event moves to status: "dead" — your failed
queue. It is neither lost nor marked as delivered: it is queryable and
redeliverable.
# What did not arrivecurl -s "$BASE/v1/events?status=dead" -H "x-api-key: $KEY"
# Attempt by attempt, with its HTTP code and latencycurl -s "$BASE/v1/events/40545" -H "x-api-key: $KEY"
# Redeliver one, or the whole queuecurl -s -X POST "$BASE/v1/events/40545/retry" -H "x-api-key: $KEY"curl -s -X POST "$BASE/v1/events/retry-dead" -H "x-api-key: $KEY"{ "id": "40545", "event_type": "payment.settled", "aggregate_type": "payment", "aggregate_id": "89920401-26da-4f69-b57d-bc20e0366ce2", "payload": { "paymentId": "89920401-26da-4f69-b57d-bc20e0366ce2", "asset": "BS", "amount": "5000000", "bankReference": "012345678901", "method": "pago_movil" }, "status": "published", "attempts": 3, "last_error": null, "next_attempt_at": null, "published_at": "2026-08-31T19:06:52.004Z", "dead_at": null, "created_at": "2026-08-31T19:06:44.120Z", "deliveries": [ { "attempt": 1, "status_code": 500, "ok": false, "error": "el receptor respondió 500", "duration_ms": 42 }, { "attempt": 2, "status_code": null, "ok": false, "error": "timeout", "duration_ms": 5001 }, { "attempt": 3, "status_code": 200, "ok": true, "error": null, "duration_ms": 87 } ]}POST /v1/events/retry-dead responds { "requeued": 7 }.
Listing events
Section titled “Listing events”curl -s "$BASE/v1/events?status=dead&eventType=payment.failed&limit=50" -H "x-api-key: $KEY"| Parameter | Values |
|---|---|
status |
pending · published · dead |
eventType |
payment.settled · payment.failed · payment.reversed |
limit · cursor |
Maximum 100. Here the cursor is the event’s numeric id |
The list does not include payload; to see it, request the event by its
id.
Testing your retries
Section titled “Testing your retries”POST /sandbox/webhooks/failing is a receiver that returns 500, and
GET /sandbox/webhooks the sink with what we sent you. See test
environment.