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. Webhook endpoint objects returned by the API carry the same key in public_key: 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: ed25519=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 | ed25519= and a base64 Ed25519 signature of the message described below. |
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. Striped25519=fromVelpay-Signature, base64-decode the rest and check it against the message with your public key. - 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, signature) {
if (!/^\d+$/.test(timestamp ?? "") || !eventId) return false;
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
if (!signature?.startsWith("ed25519=")) return false;
const message = Buffer.concat([Buffer.from(`vp.whk.1\n${timestamp}\n${eventId}\n`), rawBody]);
return verify(null, message, PUBLIC_KEY, Buffer.from(signature.slice(8), "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 $signature): bool
{
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > 300 || $eventId === '') {
return false;
}
if (strncmp($signature, 'ed25519=', 8) !== 0) {
return false;
}
$sig = base64_decode(substr($signature, 8), true);
$key = base64_decode((string) getenv('VELPAY_PUBLIC_KEY'), true);
if ($sig === false || $key === false
|| strlen($sig) !== SODIUM_CRYPTO_SIGN_BYTES
|| strlen($key) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) {
return false;
}
$message = "vp.whk.1\n" . $timestamp . "\n" . $eventId . "\n" . $rawBody;
return sodium_crypto_sign_verify_detached($sig, $message, $key);
}
$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, signature: str) -> bool:
if not timestamp or not timestamp.isdigit() or abs(time.time() - int(timestamp)) > 300 or not event_id:
return False
if not signature or not signature.startswith("ed25519="):
return False
message = f"vp.whk.1\n{timestamp}\n{event_id}\n".encode() + raw_body
try:
PUBLIC_KEY.verify(base64.b64decode(signature[8:], validate=True), message)
return True
except (InvalidSignature, ValueError):
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().
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.