Skip to content

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.

You do it yourself, with your own key. No need to write to us.

Ventana de terminal
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.

Ventana de terminal
curl -X POST $BASE/v1/tenant/webhook/test -H "x-api-key: $KEY"
200 OK
{ "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.

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.

{ "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
POST /webhooks/dispersion HTTP/1.1
content-type: application/json
x-signature: sha256=<HMAC-SHA256(your whsec, "<x-timestamp>.<raw body>")>
x-timestamp: 1785270095123
x-event-type: payment.settled
x-event-id: 40545
x-delivery-attempt: 1
Node
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 old
const 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.

  1. Verify the signature and freshness before looking at the body.

  2. Deduplicate on id. Delivery is at-least-once: the same event can arrive twice and your handler has to be idempotent. The id also travels in x-event-id.

  3. Respond 2xx fast 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.

  4. Read the full state with GET /v1/payments/{id} before closing the operation in your accounting.

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.

Ventana de terminal
# What did not arrive
curl -s "$BASE/v1/events?status=dead" -H "x-api-key: $KEY"
# Attempt by attempt, with its HTTP code and latency
curl -s "$BASE/v1/events/40545" -H "x-api-key: $KEY"
# Redeliver one, or the whole queue
curl -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"
GET /v1/events/40545
{
"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 }.

Ventana de terminal
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.

POST /sandbox/webhooks/failing is a receiver that returns 500, and GET /sandbox/webhooks the sink with what we sent you. See test environment.