Webhooks
Receive real-time notifications when payment events happen on your merchant account. Von Payments delivers signed POST requests to endpoints you register in the developer dashboard → Webhooks.
How it works
- You register a webhook endpoint in the dashboard — your URL plus the event types you want to receive (
charge.succeeded,payment_intent.failed, etc.). Von Payments mints a per-endpointwhsec_*signing secret, shown once at create time. - A payment event occurs — a charge succeeds, a refund settles, a dispute opens.
- Von Payments POSTs the event to your URL with a single
x-vonpay-signatureheader — formatt=<unix-seconds>,v1=<hmac>, with the timestamp carried inside it as thet=field (there is no separate timestamp header). The body is a signed JSON envelope. - Your handler verifies the signature, processes the event, and returns
200. Non-2xx (or no response within 10 seconds) triggers the retry curve. - On repeated failure, Von Payments retries on an exponential schedule that spans up to ~79 hours — see Webhook Retries for the schedule, response-code semantics, and circuit-breaker behavior.
Register endpoints, view delivery state, and rotate signing secrets at /dashboard/developers/webhooks.
Choose your events
Endpoints subscribe to a subset of the event catalog. The catalog covers the supported event families: charge lifecycle, payment-intent lifecycle, refunds.
charge.succeeded payment_intent.succeeded
charge.failed payment_intent.failed
charge.refunded payment_intent.cancelled
Pick the events your integration actually consumes. A new endpoint with zero events selected receives nothing — set at least one. The dashboard event picker is the canonical list of what's currently subscribable.
Envelope
Every event ships in the same envelope. See Webhook Events for per-event data shapes.
{
"id": "vp_evt_live_8x4n2pq7m1",
"type": "charge.succeeded",
"created": 1728936000,
"livemode": true,
"merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce",
"data": {
"session_id": "vp_cs_test_kJq7Lp...",
"transaction_id": "vp_tx_9f2nd...",
"amount": 1499,
"currency": "USD"
}
}
The id is unique per outbound event — use it for idempotent processing. The same id is never delivered twice with different payloads, but the same id may be redelivered after a 5xx response from your handler.
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. During a rotation window, a second v1= entry is present (signed with the previous secret); accept on any match. |
Content-Type | application/json |
User-Agent | Von-Pay-Webhooks/1.0 |
Signature verification
Signatures are HMAC-SHA256 over t.<raw-body>, keyed with your whsec_* endpoint secret. Verifiers in Node, Python, Go, Ruby, and PHP — plus a shell/openssl sanity-check snippet (debugging only, not production) — are at Webhook Verification. Copy one and run it as-is.
Key rules (the rest are on the verification page):
- HMAC the raw request body bytes, not the parsed JSON. JSON re-serialization changes whitespace and key order; the signature won't match.
- Use a constant-time compare helper (
crypto.timingSafeEqual,hmac.compare_digest, etc.) —==leaks the secret a byte at a time under timing attack. - Enforce the replay window: reject if
now - t > 300(older than 5 min) ort - now > 30(more than 30 sec in the future). - The
whsec_*secret is the raw UTF-8 string — do not base64-decode it, do not strip thewhsec_prefix.
Test your handler
Send a fully-signed synthetic event to your endpoint — including localhost — with the CLI. No tunnel (ngrok, Cloudflare Tunnel) required.
npm install -g @vonpay/checkout-cli
vonpay checkout login
vonpay checkout trigger payment_intent.succeeded --url http://localhost:3000/webhooks/vonpay
The CLI signs the payload with the same HMAC-SHA256 algorithm and x-vonpay-signature header format as the live delivery engine, but keyed with your API key (not the endpoint's whsec_* secret). So for CLI-triggered tests, point your verifier at the API key; the live engine signs with the per-endpoint whsec_*. A passing test confirms your verification code path runs, not just that the JSON parsed. vonpay checkout trigger supports session.succeeded / session.failed / payment_intent.succeeded / payment_intent.failed / payment_intent.cancelled / charge.refunded. See CLI reference for full flags.
The synthetic event uses identifiers prefixed vp_evt_test_… / vp_tx_test_… / vp_cs_test_… so your handler can tell test traffic apart from production. The User-Agent is VonPay-Webhook/1.0 (CLI trigger).
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_*
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
);
switch (event.type) {
case "charge.succeeded":
await fulfillOrder(event.data.session_id, event.data.transaction_id);
break;
case "charge.failed":
await handleFailure(event.data.session_id, event.data.failure_reason);
break;
case "charge.refunded":
await processRefund(event.data.session_id, event.data.amount);
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_*
)
if event.type == "charge.succeeded":
fulfill_order(event.data["session_id"], event.data["transaction_id"])
elif event.type == "charge.failed":
handle_failure(event.data["session_id"], event.data.get("failure_reason"))
elif event.type == "charge.refunded":
process_refund(event.data["session_id"], event.data["amount"])
return jsonify(received=True), 200
except Exception as e:
return jsonify(error=str(e)), 400
Manual verification (any language)
If you're not using an SDK, the algorithm, reference implementations in 5 languages, and a shell/openssl sanity-check snippet (debugging only) are on the Webhook Verification page.
Retry behavior
If your endpoint returns a 5xx, times out, or refuses the connection, Von Payments retries on an exponential schedule that spans up to ~79 hours: 8 attempts total at roughly 0 / 30s / 2m / 10m / 1h / 6h / 24h / 48h, each jittered to spread reconnects.
Non-recoverable 4xx responses (other than 410 Gone) mark the event dead immediately. 410 Gone disables the failing endpoint. A per-endpoint circuit breaker pauses delivery to any one URL that returns 5xx repeatedly.
Full schedule, response-code semantics, circuit-breaker thresholds, and dashboard inspection paths: Webhook Retries.
Best practices
- Respond
200quickly. Return200immediately, then process the event asynchronously. If your handler takes longer than 10 seconds, the request times out and triggers a retry. - Make your handler idempotent. You may receive the same event more than once (after a 5xx, on a manual resend from the dashboard, during a secret rotation). Deduplicate on the envelope's
idfield. - Verify the signature before trusting the payload. Treat every byte as attacker-controlled until your HMAC compare returns true.
- Use the SDK.
constructEventhandles signature verification, the replay window, multi-secret rotation, and payload parsing in one call. - Subscribe only to events you handle. Selecting
charge.*when you only consumecharge.succeededtriples your inbound volume for no benefit. - Rotate signing secrets on a schedule — quarterly is a reasonable cadence. See Webhook Signing Secrets → Rotate for the zero-downtime sequence.
What this surface doesn't cover today
There is no session.expired / "buyer abandoned the checkout" event today. The shipped catalog only fires when a charge is attempted — a buyer who lands on the hosted checkout page and closes the tab without paying never produces an event. If you need abandoned-cart signals (recovery emails, inventory release, CRM "lost opportunity" tracking), poll GET /v1/sessions/:id after the session's 30-minute TTL elapses.
A hosted-checkout / session-level webhook surface with a buyer-abandonment event (and a session.* vocabulary alongside the existing charge.*) is on the roadmap. No commitment to a delivery window — it ships when integrator pull justifies the work over other priorities.
Related
- Webhook Event Reference — full catalog, per-event payload shapes
- Webhook Signature Verification — algorithm + reference verifiers in 5 languages
- Webhook Signing Secrets — create, view-once, rotate, revoke
- Webhook Retries — schedule, response-code semantics, circuit breaker, DLQ
- Reconciliation — redirect-signal vs webhook-signal interplay