DocsWebhooks
  1. Docs
  2. Guides
  3. Start

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

HTTP
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",…}}}
HeaderMeaning
Velpay-TimestampUnix seconds when this delivery attempt was signed. Each retry gets a fresh timestamp and signature.
Velpay-IdThe event id, same as id in the body. Stable across retries: dedupe on it.
Velpay-EventSame as type in the body, handy for routing before parsing.
Velpay-DeliveryThis delivery of the event to this endpoint. A replay from the dashboard creates a new delivery.
Velpay-Attempt1 on the first try, counting up on each retry.
Velpay-Signatureed25519= 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:

  1. Reject the request if Velpay-Timestamp is more than 5 minutes away from your clock. This stops replays of old deliveries. Keep your server's clock synced (NTP).
  2. Build the message from four parts joined by a newline (\n): vp.whk.1, the timestamp, Velpay-Id and the raw body. Strip ed25519= from Velpay-Signature, base64-decode the rest and check it against the message with your public key.
  3. Answer 401 on failure. Only then parse the JSON.

Node

JavaScript
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():

TypeScript
// 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
<?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

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 "", 200

Uses the cryptography package (pip install cryptography). In Django read request.body; in FastAPI await request.body().

Handle the event

JavaScript
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.id with a unique constraint and skip ids you have seen.
  • Don't rely on order. Events can arrive out of order, for example order.paid after a late crypto payment to an order that already sent order.expired. When the current state matters, fetch GET /orders/:id.
  • Check the mode. livemode tells you whether the event is real money.
  • Match the amount. Before fulfilling, compare amount and currency with 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/resend delivers 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, filters type and order) and process those events through the same code path. Your dedupe on id makes 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:

Shell
# Cloudflare Tunnel (no account needed for a quick tunnel)
cloudflared tunnel --url http://localhost:3000

# or ngrok
ngrok http 3000
  1. Copy the https URL the tunnel prints and add https://…/webhooks/velpay as an endpoint in test mode.
  2. Create a test order and open its pay_link.
  3. Click Simulate payment. Your local server receives order.paid within seconds.
  4. Tunnel URLs change when you restart them: update or delete the endpoint afterwards.

More scenarios (half payments, expiry, refunds) are in testing.