Skip to content

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.

Variables used in this guide
BASE=https://qa.bolivarconnect.com # in production, the production URL
KEY=sk_test_... # in production, your sk_live_
  1. 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 https and public. To test from your machine, expose the receiver through a tunnel (ngrok, Cloudflare Tunnel) and register the URL it gives you. Scope tenant:write or webhooks:write.

  2. 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 your sk_live_ key.
  3. 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.test arrives, signed exactly like the real ones. If delivered is false, statusCode and error tell 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 in GET /v1/events. It works the same in both environments and moves no money.

  4. 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.

  5. 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 error
    curl -s "$BASE/v1/events/40545" -H "x-api-key: $KEY"

    Scope events:read. status is pending (still retrying), published (your server answered 2xx) or dead (all 12 attempts used up).

  6. 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 queue
    curl -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.

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
Node (Express)
import crypto from 'node:crypto';
import express from 'express';
const WHSEC = process.env.BOLIVAR_WEBHOOK_SECRET; // the whsec_ for THIS environment, outside your code
const 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')));
});

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-timestamp older than 5 minutes.
  • The same x-event-id twice is processed only once.
  • It answers 2xx within 5 s.

See before production.