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.

Dokumentacja dla programistów jest dostępna w języku angielskim.

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

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: 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",…}}}
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-SignatureA 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:

  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. Split Velpay-Signature on commas into key_…=signature pairs, base64-decode each signature and check it against the message with your public key. One that verifies is enough.
  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, 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():

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 $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

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

Uses 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:

  1. Rotate, then copy the new public key from the same page.
  2. Put it on your servers. The code above accepts a delivery when any signature verifies, so nothing is rejected in between.
  3. 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

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.