Test your webhook
This guide gets your webhook receiver tested, and you follow it on your own with your own key. The sequence is the same in QA and in production: only the base URL and the key type change. For the full format reference, see webhooks.
| QA (test) | Production | |
|---|---|---|
BASE |
https://qa.bolivarconnect.com |
The production URL we gave you |
KEY |
sk_test_… (QA only has test keys) |
sk_live_… (production only has live keys) |
whsec_ |
The QA one | The production one: you get it in step 2 with your live key |
A secret from one environment does not work in the other. Run all the steps in QA, then repeat them in production with your live key before your first payment.
BASE=https://qa.bolivarconnect.com # in production, the production URLKEY=sk_test_... # in production, your sk_live_-
Register the URL
Section titled “Register the URL”Ventana de terminal curl -s -X PUT $BASE/v1/tenant/webhook \-H "x-api-key: $KEY" -H 'content-type: application/json' \-d '{"url":"https://your-domain.com/webhooks/bolivar"}'It must be
httpsand public. To test from your machine, expose the receiver through a tunnel (ngrok, Cloudflare Tunnel) and register the URL it gives you. Scopetenant:writeorwebhooks:write. -
Get or rotate the secret (
Section titled “Get or rotate the secret (whsec_)”whsec_)If your key reached you through a link, you don’t have the secret yet: this step gives it to you. If you already have it, this same step rotates it.
Ventana de terminal curl -s -X POST $BASE/v1/tenant/webhook-secret/rotate -H "x-api-key: $KEY"201 Created { "webhookSecret": "whsec_…","warning": "Úsalo para verificar la firma (x-signature = HMAC-SHA256 de `timestamp.body`)." }- It is shown only once. Store it like you store the API key, outside your code.
- The previous one stops working immediately: everything sent from then on, retries included, is signed with the new one. Put the new one in your receiver right away, or accept both signatures while you switch.
- Scope
tenant:write. In production you rotate it with yoursk_live_key.
-
Send a test event
Section titled “Send a test event”Ventana de terminal curl -s -X POST $BASE/v1/tenant/webhook/test -H "x-api-key: $KEY"200 OK { "url": "https://your-domain.com/webhooks/bolivar", "delivered": true,"statusCode": 200, "durationMs": 143, "error": null, "hint": "…" }A
webhook.testarrives, signed exactly like the real ones. Ifdeliveredisfalse,statusCodeanderrortell you why (your server answered something other than 2xx, took longer than 5 s, or the URL is not public). This event does not go through the queue: it is not retried and it does not show up inGET /v1/events. It works the same in both environments and moves no money. -
Receive the first real event
Section titled “Receive the first real event”Real events (
payment.settled,payment.failed,payment.reversed) are sent when a payout reaches its outcome. You pay out the same way in both environments: see pay out bolívares. -
Check what we sent you
Section titled “Check what we sent you”Ventana de terminal # Latest events (without payload)curl -s "$BASE/v1/events?limit=10" -H "x-api-key: $KEY"# One event, with its payload and attempt by attempt: HTTP code, latency and errorcurl -s "$BASE/v1/events/40545" -H "x-api-key: $KEY"Scope
events:read.statusispending(still retrying),published(your server answered 2xx) ordead(all 12 attempts used up). -
Redeliver and test retries
Section titled “Redeliver and test retries”If your receiver failed (answered something other than 2xx, or did not answer), you see the attempts grow in
GET /v1/events/{id}. Once you fix it:Ventana de terminal # Redeliver one (even if it already arrived: useful to test your deduplication)curl -s -X POST "$BASE/v1/events/40545/retry" -H "x-api-key: $KEY"# Redeliver the whole dead-letter queuecurl -s -X POST "$BASE/v1/events/retry-dead" -H "x-api-key: $KEY"Scope
events:write. Redelivering moves no money: it only sends the notification again. The wait between attempts is in webhooks.
Verify the signature in your receiver
Section titled “Verify the signature in your receiver”Every delivery carries these headers:
| Header | What it is |
|---|---|
x-signature |
sha256= + hex HMAC-SHA256 of "<x-timestamp>.<raw body>" with your whsec_ |
x-timestamp |
Milliseconds since 1970, as text |
x-event-id |
Event id. Same as the id in the body |
x-event-type |
payment.settled, payment.failed, payment.reversed or webhook.test |
x-delivery-attempt |
1 the first time |
import crypto from 'node:crypto';import express from 'express';
const WHSEC = process.env.BOLIVAR_WEBHOOK_SECRET; // the whsec_ for THIS environment, outside your codeconst seen = new Set(); // in production, your database
const app = express();// The RAW body: if you parse and re-serialize the JSON, the signature won't match.app.post('/webhooks/bolivar', express.raw({ type: 'application/json' }), (req, res) => { const ts = String(req.headers['x-timestamp'] ?? ''); const signature = String(req.headers['x-signature'] ?? '').replace(/^sha256=/, ''); const expected = crypto.createHmac('sha256', WHSEC) .update(`${ts}.${req.body.toString('utf8')}`) .digest('hex');
// 1. Signature, in constant time (timingSafeEqual needs equal lengths). const a = Buffer.from(expected, 'hex'); const b = Buffer.from(signature, 'hex'); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
// 2. Freshness: ±5 minutes. if (!Number.isFinite(Number(ts)) || Math.abs(Date.now() - Number(ts)) > 5 * 60_000) { return res.sendStatus(401); }
// 3. Deduplicate by x-event-id: delivery is at-least-once. const id = String(req.headers['x-event-id']); if (seen.has(id)) return res.sendStatus(200); seen.add(id);
// 4. Answer 2xx within 5 s and process separately. res.sendStatus(200); enqueue(JSON.parse(req.body.toString('utf8')));});Checklist
Section titled “Checklist”In QA with your sk_test_, and again in production with your sk_live_
before the first payment:
- The URL is registered (step 1) and this environment’s
whsec_is stored separately (step 2). - The test event arrives with
delivered: true(step 3). - Your receiver rejects a wrong signature and an
x-timestampolder than 5 minutes. - The same
x-event-idtwice is processed only once. - It answers 2xx within 5 s.
See before production.