Webhooks
Von Payments delivers signed POST requests for payment events to endpoints you register at /dashboard/developers/webhooks — your URL plus at least one event type it should receive. Each endpoint gets its own whsec_* signing secret, shown once at create time.
Subscribe only to the event types on Webhook Events, which also has their payloads; the dashboard picker can list more than that page.
Envelope
Every event ships in the same envelope; data is per event type.
{
"id": "vp_evt_live_8x4n2pq7m1",
"type": "charge.succeeded",
"created": 1728936000,
"livemode": true,
"merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce",
"data": {
"session_id": "vp_cs_live_kJq7Lp...",
"transaction_id": "7c1f4e2a-9b3d-4f60-8e5a-2d1c0b9a8f7e",
"vp_tx_id": "vp_tx_live_9f2nd...",
"amount": 1499,
"currency": "USD"
}
}
The id is unique per outbound event — deduplicate on it. The same id is never delivered with a different payload, but it is redelivered after a 5xx from your handler or a manual resend from the dashboard.
Headers
| Header | Description |
|---|---|
x-vonpay-signature | t=<unix_seconds>,v1=<hex_hmac> — see Webhook Verification. The signing timestamp is the t= field inside this header; there is no separate timestamp header. Exactly one v1= entry is sent. |
Content-Type | application/json |
User-Agent | Von-Pay-Webhooks/1.0 |
Signature verification
Signatures are HMAC-SHA256 over t.<raw-body>, keyed with the endpoint's whsec_* secret. Reference verifiers in Node, Python, Go, Ruby and PHP are on Webhook Verification. The rules that break most ports:
- HMAC the raw request body bytes, not the parsed JSON — re-serialization changes whitespace and key order.
- Use a constant-time compare (
crypto.timingSafeEqual,hmac.compare_digest, …). - Enforce the replay window: reject if
now - t > 300ort - now > 30. - The
whsec_*secret is the raw UTF-8 string — do not base64-decode it, do not strip the prefix.
Test your handler
Send a fully-signed synthetic event to your endpoint — including localhost, no tunnel needed — with the CLI:
npm install -g @vonpay/checkout-cli
vonpay checkout login
vonpay checkout trigger payment_intent.succeeded --url http://localhost:3000/webhooks/vonpay
The CLI signs with the same algorithm and header format as live delivery but keyed with your API key, not a whsec_* secret — point your verifier at the API key for CLI tests. It can fire every charge.* and payment_intent.* event, refund.failed, mirror.order.created, and session.succeeded / session.failed; the synthetic ids are prefixed vp_evt_test_… / vp_tx_test_… / vp_cs_test_…, and the User-Agent is VonPay-Webhook/1.0 (CLI trigger). A test event is a single signed POST — it is not retried. See the CLI reference.
To test a registered endpoint with its real whsec_* secret, send a test event through the real delivery path from your server. The outcome comes back in the same call:
const outcome = await vonpay.webhookSubscriptions.sendTestEvent(subscriptionId, {
eventType: "charge.succeeded",
});
// outcome.delivered is true when your endpoint answered 2xx
In Python it is client.webhook_subscriptions.send_test_event(subscription_id, event_type="charge.succeeded"); both need SDK 3.6.0 or later and a secret key. The event carries test_event: true, and when you pass a session id it carries that session's real ids, so your handler must check test_event before touching an order. See sendTestEvent.
Code examples
Node.js (Express)
The SDK's constructEvent verifies the signature, enforces the replay window, and returns a typed event in one call.
import express from "express";
import { VonPayCheckout } from "@vonpay/checkout-node";
const vonpay = new VonPayCheckout(process.env.VON_PAY_SECRET_KEY);
const endpointSecret = process.env.VON_PAY_WEBHOOK_SECRET; // whsec_*
// Scope express.raw() to this route, and register it BEFORE any app-wide
// express.json() — a global body parser consumes the body first, and
// constructEvent verifies against the RAW bytes, so verification would fail.
app.post("/webhooks/vonpay", express.raw({ type: "application/json" }), async (req, res) => {
try {
const event = vonpay.webhooks.constructEvent(
req.body, // raw body (Buffer)
req.headers["x-vonpay-signature"] as string, // signature header
endpointSecret, // whsec_* — per-endpoint secret
);
// A "Send test event" delivery can carry a real session's ids: acknowledge it and stop.
if (event.test_event === true) {
res.status(200).json({ received: true });
return;
}
switch (event.type) {
case "charge.succeeded":
// Store vp_tx_id with the order: it is the id you reconcile and refund
// on, and the id a later charge.refunded carries to match the charge.
await fulfillOrder(event.data.session_id, event.data.payment_intent_id,
event.data.vp_tx_id ?? null);
break;
case "charge.failed":
await handleFailure(event.data.session_id, event.data.failure_reason);
break;
case "charge.refunded": {
// Match the refund to its charge on vp_tx_id, the same value the
// charge.succeeded carried; a refund is not keyed on the session.
const refund = event.data;
// amount_refunded_total is the cumulative figure and means the same
// thing on every connection. Do not read `amount` here: on some
// connections it is this refund, on others the running total. Null
// means the connection could not derive it: review it, never treat
// it as zero.
if (refund.amount_refunded_total == null) {
await flagRefundForReview(refund.vp_tx_id, refund.refund_id);
break;
}
await syncRefundTotal(refund.vp_tx_id, refund.amount_refunded_total);
break;
}
// Unknown event types: return 200, log for inspection, do not raise.
// New types may be added without an SDK bump.
}
res.status(200).json({ received: true });
} catch (err) {
res.status(400).json({ error: "Invalid signature" });
}
});
Python (Flask)
import os
from flask import Flask, request, jsonify
from vonpay.checkout import VonPayCheckout
app = Flask(__name__)
vonpay = VonPayCheckout(os.environ["VON_PAY_SECRET_KEY"])
endpoint_secret = os.environ["VON_PAY_WEBHOOK_SECRET"] # whsec_*
@app.route("/webhooks/vonpay", methods=["POST"])
def webhook():
try:
event = vonpay.webhooks.construct_event(
request.data, # raw body
request.headers.get("x-vonpay-signature"), # signature header
endpoint_secret, # whsec_*
)
# A "Send test event" delivery can carry a real session's ids: acknowledge it and stop.
if event.test_event:
return jsonify(received=True), 200
if event.type == "charge.succeeded":
# Store vp_tx_id with the order: a later charge.refunded matches on it.
fulfill_order(event.data["session_id"], event.data["payment_intent_id"], event.data.get("vp_tx_id"))
elif event.type == "charge.failed":
handle_failure(event.data["session_id"], event.data.get("failure_reason"))
elif event.type == "charge.refunded":
# Match the refund to its charge on vp_tx_id, the value the
# charge.succeeded carried. amount_refunded_total is cumulative and
# unambiguous; "amount" is not. Null means the connection could not
# derive it: review, never treat as zero.
if event.data.get("amount_refunded_total") is None:
flag_refund_for_review(event.data.get("vp_tx_id"), event.data.get("refund_id"))
else:
sync_refund_total(event.data["vp_tx_id"], event.data["amount_refunded_total"])
return jsonify(received=True), 200
except Exception as e:
return jsonify(error=str(e)), 400
Manual verification (any language)
The algorithm, reference implementations in five languages, and a shell/openssl sanity check (debugging only) are on Webhook Verification.
Retries
A 5xx, a timeout (10 seconds) or a refused connection is retried on an exponential schedule — 8 attempts at roughly 0 / 30s / 2m / 10m / 1h / 6h / 24h / 48h, each jittered. 404, 408, 425 and 429 are retried the same way; any other 4xx marks the event dead immediately; 410 Gone disables the endpoint; a per-endpoint circuit breaker pauses delivery to a URL that keeps returning 5xx. Schedule, response-code semantics and dashboard inspection: Webhook Retries.
Respond 200 quickly and process asynchronously; deduplicate on id; subscribe only to the events you handle; rotate signing secrets on a schedule — Webhook Signing Secrets → Rotate has the zero-downtime sequence.
No abandonment event
Every event follows a payment attempt. session.succeeded and session.failed report a hosted-checkout session's outcome, but fulfil on charge.succeeded, which carries the saved card and the id you refund against. There is no session.expired event; a buyer who closes the hosted page without paying produces nothing. For abandoned-cart signals, poll GET /v1/sessions/:id after the session's TTL (30 minutes by default) elapses.
Related
- Webhook Event Reference — full catalog, per-event payload shapes
- Webhook Signature Verification — algorithm + reference verifiers
- Webhook Signing Secrets — create, view-once, rotate, revoke
- Webhook Retries — schedule, response-code semantics, circuit breaker
- Reconciliation — redirect-signal vs webhook-signal interplay