Skip to content
VEYRA — Payments without borders

Documentation

Webhooks

Receive events, verify their signature, and rely on retries and replay.

Events

VEYRA sends an HTTPS POST with a JSON event to each endpoint subscribed to that event type.

{
  "id": "evt_...",
  "type": "payment_intent.succeeded",
  "created": 1760000000,
  "livemode": false,
  "api_version": "2026-10-01",
  "data": {
    "object": { "id": "pi_...", "object": "payment_intent", "amount": 12000, "currency": "EUR", "status": "succeeded" }
  }
}

Event types

payment_intent.createdpayment_intent.requires_actionpayment_intent.processingpayment_intent.succeededpayment_intent.payment_failedpayment_intent.canceledcheckout.session.completedcheckout.session.expiredcharge.refundeddispute.createddispute.closedpayout.createdpayout.paidpayout.failedpayout.returnedbank_account.verifiedbank_account.verification_failed

Signature

Each request carries a Veyra-Signature header with a timestamp and an HMAC-SHA256 signature computed with the endpoint's secret (whsec_…):

Veyra-Signature: t=1760000000,v1=5f2b8c...e9

signed_payload = "<t>.<raw request body>"
v1             = hex( HMAC-SHA256( whsec_secret, signed_payload ) )
  • Compute the HMAC over the raw body bytes, before any JSON parsing.
  • Compare with a constant-time function.
  • Reject events whose timestamp is more than 5 minutes away from your clock, to prevent replay attacks.

Verifying in your language

import { createHmac, timingSafeEqual } from "node:crypto";

const TOLERANCE_SECONDS = 5 * 60;

export function verifyVeyraSignature(rawBody: string, header: string, secret: string): boolean {
  // header: "t=1760000000,v1=5f2b...e9"
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=") as [string, string]));
  const t = Number(parts.t);
  if (!parts.v1 || !Number.isInteger(t)) return false;
  if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;

  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(parts.v1, "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

// Express: use the RAW body — not the parsed JSON.
app.post("/webhooks/veyra", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifyVeyraSignature(req.body.toString("utf8"), req.get("Veyra-Signature") ?? "", process.env.VEYRA_WEBHOOK_SECRET!);
  if (!ok) return res.sendStatus(400);
  const event = JSON.parse(req.body.toString("utf8"));
  // Handle event.type idempotently (events can be delivered more than once).
  res.sendStatus(200);
});

Delivery and retries

Respond with any 2xx status within 10 seconds. Anything else — or no response — counts as a failure, and VEYRA retries with exponential backoff for about three days:

AttemptApproximate delay after the previous one
1immediately
21 minute
35 minutes
430 minutes
52 hours
66 hours
712 hours
8–1024 hours each

At-least-once delivery

The same event can arrive more than once, and events can arrive out of order. Store processed evt_ IDs and make your handler idempotent; re-fetch the object if you need its latest state.

Replay

Every event is kept and can be resent from Developers → Events in the dashboard — useful after fixing a bug in your handler. You can also rotate an endpoint's secret there; during rotation both secrets are valid for a short overlap.

Webhooks — Documentation · VEYRA