Skip to main content

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 dashboardWebhooks.

How it works

  1. 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-endpoint whsec_* signing secret, shown once at create time.
  2. A payment event occurs — a charge succeeds, a refund settles, a dispute opens.
  3. Von Payments POSTs the event to your URL with a single x-vonpay-signature header — format t=<unix-seconds>,v1=<hmac>, with the timestamp carried inside it as the t= field (there is no separate timestamp header). The body is a signed JSON envelope.
  4. Your handler verifies the signature, processes the event, and returns 200. Non-2xx (or no response within 10 seconds) triggers the retry curve.
  5. 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

HeaderDescription
x-vonpay-signaturet=<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-Typeapplication/json
User-AgentVon-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) or t - 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 the whsec_ 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 200 quickly. Return 200 immediately, 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 id field.
  • Verify the signature before trusting the payload. Treat every byte as attacker-controlled until your HMAC compare returns true.
  • Use the SDK. constructEvent handles 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 consume charge.succeeded triples 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.