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 dashboard picker at /dashboard/developers/webhooks is generated from this catalog. New event types and new data fields may appear without an SDK version bump — treat an unknown type as a no-op (return 200, log, do not raise) and ignore unknown keys.
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 the 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. |
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; decline_code carries the same value under the API response's name, so one handler can read both surfaces by one key. 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 signal outright; decline_message gives those the ordinary decline copy. See Showing a decline to the buyer and rule declines.
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 | ⚠️ Meaning varies by processor connection — do not sum it. On some connections it is this refund; on others it is the running total refunded so far. They agree only on the first refund. Kept for backward compatibility; use refund_amount / amount_refunded_total below. |
refund_amount | integer | null | This refund only, unambiguously, on every connection. null means the connection could not derive it — which is different from 0. |
amount_refunded_total | integer | null | Cumulative refunded so far for the charge, unambiguously, on every connection. Compare this against original_charge_amount to decide "fully refunded". null means the connection could not derive it. |
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. |
amount_refunded_total, never from is_partial or by summing amountis_partial describes this one refund and is derived differently across providers — a second refund that completes the total can still report is_partial: true. amount is this refund on some connections and the running total on others, so summing it over-counts: a 100 charge refunded 60 then 30 can sum to 150. Compare amount_refunded_total on the latest event against original_charge_amount; if you need a delta, use refund_amount.
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 under charge.refunded. |
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
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. |
refund_declined is the code the live path emits. An ambiguous refund is reported to you as a synchronous provider_unavailable on your POST /v1/refunds call, not as this event — treat that response as "state unknown" in your own records and settle it with support; there is no refund-lookup endpoint to poll. The vocabulary is an open set: handle an unrecognised value as "this refund needs a human" — surface it, do not auto-retry.
Connected-platform events
mirror.order.created
Fires when a mirrored payment has created the corresponding order in your connected store. Selectable in the dashboard picker under Connected platforms.
| Field | Type | Description |
|---|---|---|
payment_intent_id | string | null | The payment this order was mirrored from — use it to correlate with charge.succeeded. |
order_number | string | null | The order's number in your store. |
order_status_url | string | null | Where the buyer can view the order in your store. |
A mirror can fail without this event firing, and a failed mirror never reverses the charge — do not treat its absence as "the payment failed". To check one charge, poll the mirror-order endpoint for the flow you used; see Connected Platforms.
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"
}
}
Dispute events
dispute.created, dispute.won, dispute.lost
Switched on for you: delivered to every active subscription whether or not you selected them — they are not in the dashboard picker, and an endpoint registered for charge.* alone still receives them, so handle them like any type you did not ask for. A subscription is still required. created fires when the processor notifies us that a cardholder has disputed a payment; won and lost as the dispute resolves. All three share one payload:
| Field | Type | Description |
|---|---|---|
dispute_id | string | null | The dispute, as the processor identifies it. |
transaction_id | string | null | The vp_tx_* of the disputed payment — the same id charge.succeeded carries, and the key to correlate on. |
amount | integer | null | The disputed amount, in minor units. |
currency | string | null | ISO 4217. |
There is no payment_intent_id on this family — correlate on transaction_id, which every charge event also carries. The events are raised from the processor's own dispute notification, so they arrive only on connections that send us one: check dispute_reporting before relying on them as your only chargeback detector.
What is delivered to a subscription
Every event except the dispute family is delivered only if its exact type is in your subscription's enabledEvents — filtered per endpoint. The subscribable surface is what the dashboard picker shows: Charges (charge.succeeded, charge.failed), Refunds (charge.refunded, refund.failed), Payment Intents (payment_intent.succeeded, payment_intent.failed, payment_intent.cancelled) and Order mirroring (mirror.order.created); each carries payment_intent_id for cross-event correlation.
session.* is not subscribable. It appears in the SDK's WebhookEvent union so a switch on type compiles, but a subscription naming session.succeeded is accepted, silently dropped, and stores nothing — it never fires and nothing reports the mismatch. Fulfil on charge.succeeded.
Check the list the API hands back. A well-formed name (lowercase, dotted) outside the catalog — session.succeeded, or a misspelling such as charge.suceeded — is dropped at subscription time and the request still returns 201; only a name that fails the format is refused with a 400. The enabledEvents in the response is the list that was stored: compare it to what you sent, and any entry that is missing will never arrive.
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