Webhooks
We POST a signed event to your server whenever an order or refund changes. Verify it, answer 2xx fast, and handle each event once.
Add an endpoint
Open Developers → Webhooks and add an https URL. Endpoints belong to one mode: add your URL once in test mode and once in live mode. You can limit an endpoint to some event types or to one store. The same page shows your account's webhook public key (base64, 32 bytes), which you need to verify signatures; GET /signing_keys returns it too. You can also create and list endpoints with POST /webhook_endpoints and GET /webhook_endpoints (see the API reference).
For a single order you can also pass notify_url on POST /orders; that URL gets the order's events in addition to your endpoints.
What we send
POST /webhooks/velpay HTTP/1.1
Content-Type: application/json
User-Agent: velpay/1 (+https://velpay.pro/docs/guides/webhooks)
Velpay-Timestamp: 1793400123
Velpay-Id: evt_9aZ…
Velpay-Event: order.paid
Velpay-Delivery: dlv_…
Velpay-Attempt: 1
Velpay-Signature: key_4fQ9aZk2LmP0=3q0X…base64…Ag==
{"object":"event","id":"evt_9aZ…","type":"order.paid","livemode":true,"created":1793400123,"data":{"object":{"object":"order","id":"ord_…","state":"paid",…}}}| Header | Meaning |
|---|---|
Velpay-Timestamp | Unix seconds when this delivery attempt was signed. Each retry gets a fresh timestamp and signature. |
Velpay-Id | The event id, same as id in the body. Stable across retries: dedupe on it. |
Velpay-Event | Same as type in the body, handy for routing before parsing. |
Velpay-Delivery | This delivery of the event to this endpoint. A replay from the dashboard creates a new delivery. |
Velpay-Attempt | 1 on the first try, counting up on each retry. |
Velpay-Signature | A key id, = and a base64 Ed25519 signature of the message described below, for each signing key, separated by commas. Usually one; two for 7 days after you rotate your key. |
data.object is the full object as the API returns it: an order for order.* events, a refund for refund.* events. Event types: order.created, order.processing, order.paid, order.underpaid, order.expired, order.voided, charge.failed, refund.created, refund.sent, refund.failed, dispute.opened, dispute.closed, payout.paid.
charge.failed is sent once when a payment attempt ends without paying (expired, declined, method unavailable, payment not verified). An expired crypto invoice doesn't send it: late payments to its address are still credited for 7 days. Its data.object is the order; data.charge is the failed charge and data.failure_code is one of expired, declined, method_unavailable, payment_unverified, processing_error (see the API reference). The order usually stays open, so don't cancel anything on it: wait for order.paid or order.expired.
Verify the raw body
The signature covers the exact bytes we sent. Read the body as bytes or a string before any JSON parsing: if your framework parses and re-serializes it, key order or whitespace can change and the check fails. Then:
- Reject the request if
Velpay-Timestampis more than 5 minutes away from your clock. This stops replays of old deliveries. Keep your server's clock synced (NTP). - Build the message from four parts joined by a newline (
\n):vp.whk.1, the timestamp,Velpay-Idand the raw body. SplitVelpay-Signatureon commas intokey_…=signaturepairs, base64-decode each signature and check it against the message with your public key. One that verifies is enough. - Answer
401on failure. Only then parse the JSON.
Node
import express from "express";
import { createPublicKey, verify } from "node:crypto";
// VELPAY_PUBLIC_KEY: the base64 key from Developers → Webhooks (32 raw bytes).
const PUBLIC_KEY = createPublicKey({
key: Buffer.concat([Buffer.from("302a300506032b6570032100", "hex"), Buffer.from(process.env.VELPAY_PUBLIC_KEY, "base64")]),
format: "der",
type: "spki",
});
/** rawBody: the request body as a Buffer, exactly as received. */
function verifyVelpay(rawBody, timestamp, eventId, signatures) {
if (!/^\d+$/.test(timestamp ?? "") || !eventId) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
const message = Buffer.concat([Buffer.from(`vp.whk.1\n${timestamp}\n${eventId}\n`), rawBody]);
// "key_…=<signature>" per signing key (two during a rotation): accept if any verifies.
return (signatures ?? "").split(",").some((pair) => {
const eq = pair.indexOf("=");
return eq > 0 && verify(null, message, PUBLIC_KEY, Buffer.from(pair.slice(eq + 1).trim(), "base64"));
});
}
const app = express();
// express.raw keeps the bytes; express.json() would re-serialize them and break the signature.
app.post("/webhooks/velpay", express.raw({ type: "application/json" }), async (req, res) => {
if (!verifyVelpay(req.body, req.get("Velpay-Timestamp"), req.get("Velpay-Id"), req.get("Velpay-Signature"))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
if (await seen(event.id)) return res.sendStatus(200); // already handled
await queue.add(event); // do the work after answering
res.sendStatus(200);
});In a Next.js route handler, read the bytes with arrayBuffer():
// app/api/webhooks/velpay/route.ts (Next.js App Router)
export async function POST(req: Request) {
const raw = Buffer.from(await req.arrayBuffer());
const h = req.headers;
if (!verifyVelpay(raw, h.get("velpay-timestamp"), h.get("velpay-id"), h.get("velpay-signature"))) {
return new Response("bad signature", { status: 401 });
}
const event = JSON.parse(raw.toString("utf8"));
// …
return new Response(null, { status: 200 });
}PHP
<?php
// Needs the sodium extension (bundled with PHP 7.2+).
function verify_velpay(string $rawBody, string $timestamp, string $eventId, string $signatures): bool
{
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300 || $eventId === '') {
return false;
}
$key = base64_decode((string) getenv('VELPAY_PUBLIC_KEY'), true);
if ($key === false || strlen($key) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
return false;
}
$message = "vp.whk.1\n" . $timestamp . "\n" . $eventId . "\n" . $rawBody;
// "key_…=<signature>" per signing key (two during a rotation): accept if any verifies.
foreach (explode(',', $signatures) as $pair) {
$eq = strpos($pair, '=');
$sig = $eq ? base64_decode(trim(substr($pair, $eq + 1)), true) : false;
if ($sig !== false && strlen($sig) === SODIUM_CRYPTO_SIGN_BYTES
&& sodium_crypto_sign_verify_detached($sig, $message, $key)) {
return true;
}
}
return false;
}
$raw = file_get_contents('php://input'); // raw bytes, not $_POST
$ok = verify_velpay(
$raw,
$_SERVER['HTTP_VELPAY_TIMESTAMP'] ?? '',
$_SERVER['HTTP_VELPAY_ID'] ?? '',
$_SERVER['HTTP_VELPAY_SIGNATURE'] ?? ''
);
if (!$ok) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// … dedupe on $event['id'], then handle $event['type']
http_response_code(200);On WordPress, use $request->get_body() in a REST route, or simply install the WooCommerce plugin, which does all of this.
Python
import base64, os, time
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from flask import Flask, request
PUBLIC_KEY = Ed25519PublicKey.from_public_bytes(base64.b64decode(os.environ["VELPAY_PUBLIC_KEY"]))
def verify_velpay(raw_body: bytes, timestamp: str, event_id: str, signatures: str) -> bool:
if not timestamp or not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300 or not event_id:
return False
message = f"vp.whk.1\n{timestamp}\n{event_id}\n".encode() + raw_body
# "key_…=<signature>" per signing key (two during a rotation): accept if any verifies.
for pair in (signatures or "").split(","):
key_id, eq, sig = pair.strip().partition("=")
if not key_id or not eq:
continue
try:
PUBLIC_KEY.verify(base64.b64decode(sig, validate=True), message)
return True
except (InvalidSignature, ValueError):
continue
return False
app = Flask(__name__)
@app.post("/webhooks/velpay")
def velpay_webhook():
raw = request.get_data() # bytes as received; don't use request.json here
h = request.headers
if not verify_velpay(raw, h.get("Velpay-Timestamp"), h.get("Velpay-Id"), h.get("Velpay-Signature")):
return "", 401
event = request.get_json()
# … dedupe on event["id"], then handle event["type"]
return "", 200Uses the cryptography package (pip install cryptography). In Django read request.body; in FastAPI await request.body().
Rotate the signing key
Rotate key under Developers → Webhooks creates a new key that signs at once. The old key keeps signing next to it for 7 days, so every delivery carries both signatures and your servers keep verifying while you switch:
- Rotate, then copy the new public key from the same page.
- Put it on your servers. The code above accepts a delivery when any signature verifies, so nothing is rejected in between.
- After 7 days the old key stops signing. Once every server has the new key you can end it sooner with Stop now.
GET /signing_keys lists the keys that sign right now; the one being replaced has retires set. At most three keys sign at once.
Handle the event
switch (event.type) {
case "order.paid":
// Look the order up by event.data.object.id (or metadata / reference) and fulfil it once.
await fulfil(event.data.object);
break;
case "order.underpaid":
await flagForReview(event.data.object); // amount_paid < amount
break;
case "order.expired":
case "order.voided":
await releaseStock(event.data.object);
break;
case "refund.sent":
await markRefunded(event.data.object); // a refund object: order, amount, tx_hash
break;
case "charge.failed":
// One payment attempt failed; the order usually stays open for another try.
await noteAttempt(event.data.object.id, event.data.failure_code); // e.g. "expired", "declined"
break;
default:
// New event types can appear at any time. Ignore what you don't know, but answer 2xx.
}- Answer fast. Any 2xx within 15 seconds counts as delivered. Store the event, answer, and do slow work (emails, fulfilment) in a job.
- Handle each event once. The same event can arrive more than once (a retry after a timeout, a replay). Record
event.idwith a unique constraint and skip ids you have seen. - Don't rely on order. Events can arrive out of order, for example
order.paidafter a late crypto payment to an order that already sentorder.expired. When the current state matters, fetchGET /orders/:id. - Check the mode.
livemodetells you whether the event is real money. - Match the amount. Before fulfilling, compare
amountandcurrencywith what you expected for your order.
Retries
If your endpoint doesn't answer 2xx within 15 seconds, we retry 12 times, roughly after 2, 5, 10, 20, 40 and 80 minutes, then about 2.5, 5, 8, 12, 16 and 22 hours. Each gap varies by up to 12% so retries from many senders don't arrive at once. The whole schedule spans close to three days. We don't follow redirects, so point the endpoint at the final URL. When deliveries to an endpoint keep giving up, we switch the endpoint off and email your account owners and admins.
Answer 401 or 400 for requests you reject, 5xx when you want us to try again later (database down, dependency failing).
Replay and catching up
- Every event, its deliveries and your endpoint's responses are listed in the dashboard under Developers → Events. Resend a single event from there, or, under Developers → Webhooks, replay every failed delivery of an endpoint since a date, for example after fixing a bug. From code,
POST /api/v1/events/:id/resenddelivers an event again (up to 30 days old), signed afresh with the same event id. - After downtime, list what you missed with
GET /api/v1/events(newest first, filterstypeandorder) and process those events through the same code path. Your dedupe onidmakes this safe. - A disabled endpoint stays off until you switch it back on in the dashboard.
Testing on your machine
We only deliver to public https URLs (private and local addresses are refused), so expose your local server through a tunnel:
# Cloudflare Tunnel (no account needed for a quick tunnel)
cloudflared tunnel --url http://localhost:3000
# or ngrok
ngrok http 3000- Copy the https URL the tunnel prints and add
https://…/webhooks/velpayas an endpoint in test mode. - Create a test order and open its
pay_link. - Click Simulate payment. Your local server receives
order.paidwithin seconds. - Tunnel URLs change when you restart them: update or delete the endpoint afterwards.
More scenarios (half payments, expiry, refunds) are in testing.