Webhook Event Reference
Every webhook Von Payments delivers shares the same envelope. The type field tells you which event it is; the data field holds the per-event payload. The event families below cover what the public ecommerce integration surface supports today. The event picker at /dashboard/developers/webhooks is the canonical list of what's available to subscribe to from your dashboard right now.
Event families are added to the catalog as each capability ships. New event types may appear without an SDK version bump — handlers should treat unknown type values as a no-op (return 200 OK, log for inspection, do not raise).
Envelope
Every event Von Payments delivers is wrapped in the same envelope:
{
"id": "vp_evt_live_8x4n2pq7m1",
"type": "charge.succeeded",
"created": 1728936000,
"livemode": true,
"merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce",
"data": { /* per-event payload, see sections below */ }
}
| Field | Type | Notes |
|---|---|---|
id | string | Unique per outbound event. Use this for idempotent processing — the same event ID is never delivered twice with different payloads. |
type | string | Event type from the catalog below. Treat unknown event types as a no-op (forward-compatible: new types may appear without an SDK bump). |
created | integer | Unix seconds when Von Payments emitted the event. For replay-window enforcement, compare against the t= field inside the x-vonpay-signature header (there is no separate timestamp header). |
livemode | boolean | true for events from a live merchant; false for sandbox / test-mode dispatches. |
merchant_id | string | The merchant the event belongs to (a UUID). On a multi-tenant platform integrator, route on this value. |
data | object | Per-event payload. Shape is fixed per type; see sections below. |
All amounts in minor units (cents for USD, pence for GBP, etc.). All currency codes uppercase ISO-4217. All timestamps as unix seconds.
Charge events
The charge.* family fires for the card-charge lifecycle when a session reaches settlement (or a direct /v1/payment_intents charge succeeds without a session wrapper).
charge.succeeded
A charge completed successfully. Fires after the buyer finishes checkout and the processor confirms the charge.
{
"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...",
"payment_intent_id": "vpi_live_9f2ndx7k...",
"transaction_id": "vp_tx_live_9f2nd...",
"amount": 1499,
"currency": "USD",
"card": { "brand": "visa", "last4": "4242" }
}
}
data field | Type | Description |
|---|---|---|
session_id | string | null | The session this charge belongs to. Null for direct-charge flows that do not go through /v1/sessions. |
payment_intent_id | string | null | Payment intent (vpi_*) this charge settled. Null on hosted-direct flows that never minted an intent. |
transaction_id | string | null | The charge transaction ID. Use this for reconciliation against the merchant's transaction list. |
amount | integer | Amount charged, in minor units. |
currency | string | ISO-4217 uppercase. |
card | object | null | PCI-safe card presentation — { "brand": ..., "last4": ... }. brand is one of visa, mastercard, amex, discover, diners, jcb, unionpay, unknown; last4 is 4 digits. null until card enrichment is active for the merchant. No PAN, BIN, or fingerprint is ever included. |
avs_result_code | string | null | Normalized Address Verification (AVS) result — match, partial_postal, partial_address, no_match, unavailable, or not_supported. null when no billing address was sent or no result was returned. |
cvv_result_code | string | null | Normalized card security-code result — match, no_match, not_provided, or unavailable. |
charge.failed
A charge attempt failed. Includes a failure_reason describing the decline.
{
"id": "vp_evt_live_3x8m1q4r5s",
"type": "charge.failed",
"created": 1728936010,
"livemode": true,
"merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce",
"data": {
"session_id": "vp_cs_live_pK7nLm...",
"payment_intent_id": "vpi_live_8d4nex2p...",
"transaction_id": "vp_tx_live_8d4ne...",
"amount": 1499,
"currency": "USD",
"failure_reason": "Your card was declined.",
"failure_code": "card_declined",
"decline_code": "card_declined",
"action": "generic",
"decline_message": "The card was declined. Please use a different payment method.",
"network_decline_code": "05",
"rule_code": null,
"card": { "brand": "visa", "last4": "4242" }
}
}
failure_code is the normalized decline code (card_declined, insufficient_funds, expired_card, fraudulent, processing_error, …) you should branch on, and decline_code is that same value under the API response's name. action is the retry classification — soft / hard / fix_and_retry / generic — from the same published mapping the API response uses. network_decline_code is the raw issuer code (05, 51, …); rule_code names which of your own provider rules refused the charge, and is null unless failure_code is blocked_by_rule. All seven are string | null. The five derived from the normalized decline code — failure_code, decline_code, action, decline_message and rule_code — are populated or null together, so handle the whole block being absent rather than testing one field. card carries the PCI-safe { brand, last4 } (or null until card enrichment is active) — same shape as on charge.succeeded.
Show decline_message to the buyer; keep failure_reason for your logs. failure_reason is written for you, and for fraudulent, stolen_card and lost_card it states the fraud, stolen or lost signal outright — wording you should not repeat back at checkout. decline_message gives those the ordinary decline copy instead. See Showing a decline to the buyer.
failure_code and decline_code carry the same value on this payload — failure_code is the original name and keeps working, decline_code matches what the API response calls it. rule_code is named the same on both surfaces. See rule declines and the error reference.
charge.refunded
A charge was refunded — full or partial. Fires once per refund record. A fully-refunded charge that was issued in two partial refunds fires this event twice.
{
"id": "vp_evt_live_7t6r5w4v3u",
"type": "charge.refunded",
"created": 1729022400,
"livemode": true,
"merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce",
"data": {
"session_id": "vp_cs_live_kJq7Lp...",
"payment_intent_id": "vpi_live_9f2ndx7k...",
"transaction_id": "vp_tx_live_9f2nd...",
"refund_id": "vpr_live_3kQpL2nM...",
"amount": 500,
"currency": "USD",
"reason": "customer_request",
"is_partial": true,
"original_charge_amount": 1499,
"card": { "brand": "visa", "last4": "4242" }
}
}
data field | Type | Description |
|---|---|---|
payment_intent_id | string | null | Payment intent (vpi_*) of the original charge. |
refund_id | string | null | Refund record id (vpr_*) — identifies WHICH refund in a multi-partial sequence this event references. |
amount | integer | Amount of THIS refund in minor units (not the original charge total — see original_charge_amount). To get the cumulative refunded total, sum amount across all charge.refunded events for the same transaction_id. |
reason | string | null | Refund reason — customer_request / duplicate / fraudulent / merchant free-form. |
is_partial | boolean | null | Whether this refund was for less than the full charge. ⚠ Do not use it to decide whether a charge is now fully refunded — see the note below. |
card | object | null | PCI-safe { brand, last4 } of the refunded card (or null until card enrichment is active) — same shape as on charge.succeeded. |
original_charge_amount | integer | null | Full charge total before any refund — lets you compute the remaining refundable balance without a round-trip. |
is_partialis_partial describes this one refund, and how it is derived is not identical across payment providers. On some, a second refund that completes the total still reports is_partial: true, because it compares that refund against the original charge rather than the running total.
Concretely: a 100 charge refunded 60 and then 40 is fully refunded — but the second event can still say is_partial: true.
The reliable method, which works on every provider: dedupe first — a redelivered event carries the same envelope id, and counting it twice can make a partial refund look complete — then sum amount across the distinct charge.refunded events for a transaction_id and compare against original_charge_amount. Both of those mean the same thing everywhere.
If your order logic currently settles a refund on is_partial, check it — that is the case this gets wrong.
refund.failed
A refund you issued did not complete. The buyer has not been paid back, and the refund needs attention.
This is the failure twin of charge.refunded and deliberately mirrors its shape, so you can reuse the same row mapping. It adds two fields: reason_code and retry_available.
amount here is the reversal amount that failed to move — no money moved on this event.
{
"id": "vp_evt_live_2k9m4x7p1q",
"type": "refund.failed",
"created": 1729022400,
"livemode": true,
"merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce",
"data": {
"session_id": "vp_cs_live_kJq7Lp...",
"payment_intent_id": "vpi_live_9f2ndx7k...",
"transaction_id": "vp_tx_live_9f2nd...",
"refund_id": "vpr_live_3kQpL2nM...",
"amount": 500,
"currency": "USD",
"reason": "requested_by_customer",
"reason_code": "refund_declined",
"is_partial": true,
"original_charge_amount": 1499,
"retry_available": true,
"card": { "brand": "visa", "last4": "4242" }
}
}
data field | Type | Description |
|---|---|---|
refund_id | string | null | Refund record id (vpr_*) — identifies which refund failed. |
amount | integer | null | The reversal amount that failed to move, in minor units. Nothing was transferred. |
reason | string | null | The refund reason originally recorded, carried through from the refund itself. |
reason_code | string | null | Why it failed. Branch on this — see below. |
retry_available | boolean | null | Whether retrying is a supported action for this failure. |
is_partial | boolean | null | Whether this refund was for less than the full charge. ⚠ Do not use it to decide whether a charge is now fully refunded — see the note below. |
original_charge_amount | integer | null | Full charge total before any refund. |
card | object | null | PCI-safe { brand, last4 } — same shape as on charge.refunded. |
Branch on reason_code — the safe action is different for each
reason_code | What happened | What is safe to do |
|---|---|---|
refund_declined | We attempted the reversal and the provider refused it outright. | Retrying unchanged will fail the same way. Find out why before retrying. |
refund_unresolved | We attempted the reversal and never got an answer. The money may or may not have moved. | ⛔ Do not retry blind — reissuing a refund that silently succeeded pays the buyer twice. Contact support to establish what happened before you retry. |
Treating these two the same is the expensive mistake. "We tried and were refused" and "we tried and don't know" call for opposite responses.
refund_declined is the code the live path emits. refund_unresolved is declared in the contract but is not emitted yet — the ambiguous-outcome case it names is currently reported to you as a synchronous provider_unavailable error on your POST /v1/refunds call, not as this webhook.
That matters for what you build: an ambiguous refund will not reach you as a refund.failed event today, so do not let this event be your only detector for one. Treat a provider_unavailable (or a timeout) on a refund request as "state unknown" in your own records and reconcile it — there is no refund-lookup endpoint to poll, so an unresolved refund needs support to settle.
We are documenting the code now because it is part of the declared vocabulary and because handling it wrongly is the expensive direction. Do not read its presence here as a signal you can rely on arriving.
:::
reason_code is an open set — always write a fallback branchThe values above are what ships today, but the vocabulary will grow. Do not write an exhaustive switch with no default: a new code shipping later would fall through it silently.
Handle any unrecognised value as "this refund needs a human" — surface it, don't auto-retry it. That is the safe default for a code whose meaning you do not yet know.
Payment intent events
The payment_intent.* family fires for the discrete-lifecycle API (POST /v1/payment_intents). If your integration uses the hosted-checkout sessions flow only, you can ignore these. If you call paymentIntents.create directly via the SDK, these are the events that confirm terminal state.
payment_intent.succeeded
A payment intent reached terminal succeeded status.
{
"id": "vp_evt_live_5p3q2t1u8v",
"type": "payment_intent.succeeded",
"created": 1728936000,
"livemode": true,
"merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce",
"data": {
"session_id": null,
"payment_intent_id": "vpi_live_abc123x7...",
"transaction_id": "vp_tx_live_abc123",
"amount": 1499,
"currency": "USD"
}
}
session_id is null for payment intents created outside the hosted-checkout flow.
payment_intent.failed
A payment intent reached terminal failed status.
{
"id": "vp_evt_live_9w8x7y6z1a",
"type": "payment_intent.failed",
"created": 1728936010,
"livemode": true,
"merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce",
"data": {
"session_id": null,
"payment_intent_id": "vpi_live_xyz789k2...",
"transaction_id": "vp_tx_live_xyz789",
"amount": 1499,
"currency": "USD",
"failure_reason": "Your card was declined.",
"failure_code": "card_declined",
"network_decline_code": "05",
"rule_code": null
}
}
payment_intent.cancelled
A payment intent was cancelled before reaching a terminal state. Typically fires when an authorized intent is voided before capture.
{
"id": "vp_evt_live_2b1c4d3e5f",
"type": "payment_intent.cancelled",
"created": 1728936020,
"livemode": true,
"merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce",
"data": {
"session_id": null,
"payment_intent_id": "vpi_live_def456m9...",
"transaction_id": "vp_tx_live_def456",
"amount": 1499,
"currency": "USD",
"cancellation_reason": "buyer_abandoned"
}
}
Forward compatibility
New event types may be added to this catalog without an SDK version bump. Handlers should treat unknown type values as no-ops (return 200 OK, log for inspection, do not raise). New fields may be added to existing data payloads at any time; consumers should ignore unknown keys without failing.
You receive an event only if its exact type is in your subscription's enabledEvents — delivery is filtered per endpoint, so subscribing to charge.* does not deliver session.*. The seven events above are the standard ecommerce surface offered in the dashboard event picker, grouped there as Charges (charge.succeeded, charge.failed), Refunds (charge.refunded, refund.failed), and Payment Intents (payment_intent.succeeded, payment_intent.failed, payment_intent.cancelled); build your fulfilment on those (each carries payment_intent_id for cross-event correlation). Other catalog types — session.* and dispute.* — are modeled in the SDK's WebhookEvent union so you can switch on type forward-compatibly, but an endpoint receives one only if that exact type is in its enabledEvents.
Related
- Webhooks — overview: how the surface works, registering endpoints, retry behavior
- Webhook Verification — HMAC-SHA256 verification across languages
- Webhook Signing Secrets — create, view-once, rotate