Set up, verify and recover webhooks
Register an endpoint, verify every signature on the raw body, deduplicate deliveries, and recover an event you missed — webhooks are sent once, with no retry.
What you will do
VEXPay signs every webhook and sends it once, with a 10-second timeout and no automatic retry. Your endpoint must verify the signature, answer 2xx quickly, ignore duplicates, and your system must be able to look up anything it missed. Do this once on Test and once on Live — they have separate endpoints and secrets.
- A public HTTPS URL. Localhost, private IPs,
*.internal/*.localnames, URLs with a username or password, and redirects are refused. - Access to the raw request body in your web framework (signatures are computed on the exact bytes).
Register the endpoint
- Create it
In Portal → Webhooks → New endpoint, or with
POST /v1/webhooks. List only the events you handle. - Save the secret
The signing secret is returned only in the create response. Store it as
VEXPAY_WEBHOOK_SECRET. If you lose it, create a new endpoint and delete the old one — the secret cannot be changed later. - Send yourself a test
Click Send test in the portal or call
POST /v1/notifications/test(up to 10 every 10 minutes). You receive anotification.testevent.
curl --request POST "${API_URL}/v1/webhooks" \
--header "content-type: application/json" \
--header "x-api-key: ${VEXPAY_API_KEY}" \
--data '{
"url": "https://shop.example/webhooks/vexpay",
"events": ["payment.completed", "payment.failed", "payment.canceled", "payment.reversed", "payout.completed", "payout.failed"]
}'
# Save "secret" from the response now.Change the URL, the event list or turn an endpoint off with PATCH /v1/webhooks/:id (the event list is replaced as a whole). The full event catalogue is at Webhooks.
Verify every delivery
Each delivery is a POST with a JSON body { event, data, timestamp } and the header VexPay-Signature: t=<unix seconds>,v1=<hex>. v1 is the HMAC-SHA256 of "<t>.<raw body>" with your endpoint secret. Accept the delivery when any v1 value matches and t is within 5 minutes of your clock.
app.post('/webhooks/vexpay', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = vexpay.webhooks.constructEvent(
req.body, // raw bytes, not parsed JSON
req.headers['vexpay-signature'],
process.env.VEXPAY_WEBHOOK_SECRET,
);
} catch {
return res.sendStatus(400);
}
const eventId = req.headers['vexpay-event-id'];
if (await alreadyProcessed(eventId)) return res.sendStatus(200);
await queue.add({ eventId, event }); // do the work after answering
res.sendStatus(200);
});import crypto from 'node:crypto';
function verify(rawBody, header, secret, toleranceSec = 300) {
const parts = header.split(',').map((p) => p.split('='));
const t = Number(parts.find(([k]) => k === 't')?.[1]);
const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
return signatures.some(
(s) => s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)),
);
}- Deduplicate on the
VexPay-Event-Idheader: the same event can reach you more than once. - Answer 2xx within 10 seconds and do slow work in a queue. A timeout counts as a failed delivery and is not retried.
- Events can arrive out of order. Decide from the object's current status, not from the order of events.
- Fulfil only from a verified
payment.completedor from a GET that shows the payment completed. Browser redirects are hints, never proof.
Recover an event you missed
If your endpoint was down or answered slowly, the event will not be sent again. Look the object up instead:
- Find failed deliveries
Portal → Webhooks → Delivery history shows each delivery with its status, HTTP code and the start of your response.
- Look the object up
GET /v1/payments/:id, orGET /v1/payments/by-ref/:externalRefwhen you never got an id. COP:GET /v1/cop/payments/:id. Checkout:GET /v1/checkout/sessions/:id. Payouts:GET /v1/payouts/:id,GET /v1/payouts/by-ref/:externalRef,GET /v1/crypto/payouts/:id. - Catch up on money movements
Page through
GET /v1/balance/transactions(bolívares) since your last processed movement, and book each movement once by itsid. - Run it on a schedule
A periodic job that checks orders still "waiting for payment" against the API closes the gap for good.
curl "${API_URL}/v1/payments/by-ref/order-1042" \
--header "x-api-key: ${VEXPAY_API_KEY}"Check that it worked
- Send test shows a successful delivery with your 2xx code in Delivery history.
- A delivery with a changed body or a wrong secret is rejected by your endpoint.
- Replaying the same delivery does not process the order twice.
If something goes wrong
| Symptom | Likely cause | What to do |
|---|---|---|
| Signature never matches | The body was parsed and re-serialized before verifying, the wrong secret, or a Test secret on Live | Verify the raw bytes with the secret of that endpoint. |
| SDK says the timestamp is outside the tolerance | Server clock is off | Sync your clock (NTP). |
| Delivery failed with a 3xx code | Your URL redirects | Register the final URL. |
| Delivery failed, timeout | Your endpoint took more than 10 seconds | Store the event, answer 2xx, then process. |
| Delivery failed with "blocked" | The host resolves to a private address | Fix the DNS record; blocked deliveries are not retried. |
| An event never arrived | Not in the endpoint's event list, or the endpoint was down | Add it with PATCH /v1/webhooks/:id, then recover as above. |
| 429 on test sends | More than 10 tests in 10 minutes | Wait a few minutes. |
Related: Webhooks · Reconcile your balance · Idempotency