Developer guide
VEXPay siteAPI reference
Browse documentation

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/*.local names, 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

  1. Create it

    In Portal → Webhooks → New endpoint, or with POST /v1/webhooks. List only the events you handle.

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

  3. Send yourself a test

    Click Send test in the portal or call POST /v1/notifications/test (up to 10 every 10 minutes). You receive a notification.test event.

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

Node SDKjavascript
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);
});
Without an SDK (Node)javascript
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-Id header: 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.completed or 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:

  1. Find failed deliveries

    Portal → Webhooks → Delivery history shows each delivery with its status, HTTP code and the start of your response.

  2. Look the object up

    GET /v1/payments/:id, or GET /v1/payments/by-ref/:externalRef when 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.

  3. Catch up on money movements

    Page through GET /v1/balance/transactions (bolívares) since your last processed movement, and book each movement once by its id.

  4. Run it on a schedule

    A periodic job that checks orders still "waiting for payment" against the API closes the gap for good.

Look up by your referencebash
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

SymptomLikely causeWhat to do
Signature never matchesThe body was parsed and re-serialized before verifying, the wrong secret, or a Test secret on LiveVerify the raw bytes with the secret of that endpoint.
SDK says the timestamp is outside the toleranceServer clock is offSync your clock (NTP).
Delivery failed with a 3xx codeYour URL redirectsRegister the final URL.
Delivery failed, timeoutYour endpoint took more than 10 secondsStore the event, answer 2xx, then process.
Delivery failed with "blocked"The host resolves to a private addressFix the DNS record; blocked deliveries are not retried.
An event never arrivedNot in the endpoint's event list, or the endpoint was downAdd it with PATCH /v1/webhooks/:id, then recover as above.
429 on test sendsMore than 10 tests in 10 minutesWait a few minutes.

Related: Webhooks · Reconcile your balance · Idempotency