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. Subscribe only to the event types on this page: the dashboard picker at /dashboard/developers/webhooks can list more than it. 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 on a live key; false for every event on a test key, real test payments included. It does not tell you an event came from Send test event: check test_event for that. |
test_event | true, or absent | Present only on an event from Send test event (the dashboard button, or POST /v1/webhook_subscriptions/{id}/send_test_event), and then always true. Absent, never false, on every real event. Check it first, before any lookup or write, and return 2xx without doing anything else: a test event sent for a session carries that session's real ids, a live session's included, so the rest of the payload can look like a real payment. |
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": "7c1f4e2a-9b3d-4f60-8e5a-2d1c0b9a8f7e",
"vp_tx_id": "vp_tx_live_9f2nd...",
"amount": 1499,
"currency": "USD",
"card": { "brand": "visa", "last4": "4242" },
"payment_method_id": null
}
}
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. POST /v1/refunds takes it as payment_intent. |
vp_tx_id | string | null | The Von transaction id. Reconcile and refund on this. It is the value the SDK's submit result returns as transactionId, the id your dashboard's transaction list shows, and what POST /v1/refunds takes as transaction. null on a vault-forward charge (its settlement row cannot be named) and whenever the settlement row could not be read at delivery time; the event is still sent. When it is null, key on payment_intent_id instead. |
transaction_id | string | null | The card processor's reference for the charge, not a Von id. On Embedded Fields, hosted checkout and every asynchronously settled payment it is the processor's transaction id (a UUID on one connection, a pi_… reference on another); in the sandbox it is sbx_pi_*. Every event about one charge carries the same value, so it correlates events with each other. It is not the vp_tx_* your dashboard shows: never match it against your transaction list or pass it to a refund. Use vp_tx_id. |
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. |
payment_method_id | string | null | The saved card (vp_pmt_*) this payment stored, to charge again with POST /v1/payment_intents payment_method: { id }. null when nothing was saved, for example a guest payment. |
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": "d2e5b7c1-3a9f-4e08-b6c4-1f0a2d3e4b5c",
"vp_tx_id": null,
"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,
"retry": { "allowed": true, "attempts_remaining": 2 },
"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. vp_tx_id is always null here: a declined charge writes no settlement row, and the key is present so the charge.* payloads share one shape. transaction_id is the processor's reference for the attempt, when it reported one.
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.
A failed charge is not always the end of the checkout
Before you cancel an order, release stock or email the buyer on a failure event, read retry. On accounts with retry on the same session switched on, a declined buyer can try another card on the same checkout session, including after the bank refuses the payment on its 3-D Secure page. A failure event is sent for each declined attempt, so a buyer who tries a second card and succeeds produces a charge.failed followed by a charge.succeeded.
retry is on charge.failed, payment_intent.failed and session.failed, with the same meaning on all three. It describes the session after this decline was handled, and agrees with retry on GET /v1/sessions/{id}:
retry | What it means | What to do with the order |
|---|---|---|
{ "allowed": true, "attempts_remaining": n } | The session is open again and the buyer can pay with another card. | Keep it open. If the buyer pays, charge.succeeded follows. If they leave, nothing more is sent: close the session (below) before you cancel. |
{ "allowed": false, "attempts_remaining": n }, n above 0 | Open again; the form for the next card is still being prepared (about a second). | Keep it open, as above. |
{ "allowed": false, "attempts_remaining": 0 } | No more cards can be tried on this session. | Close the session (below) before you cancel. |
null | This field does not decide it: retry on the same session is not switched on for your account, the session is not elements + chargeAtSubmit, the decline handed the session back without using up an attempt (it can still be paid), the event is about an earlier attempt, or the session has since expired (a bank check that timed out: the session reads expiredFrom: "failed", which is final). | Not final by itself. Read GET /v1/sessions/{id} before you cancel; a session can still take a payment until it succeeds or expires (session rules). |
Close the session before you cancel the order. Call POST /v1/sessions/{id}/expire. A 200 means no payment is in progress on it and none can start, so cancelling is safe. A 409 means not yet: with reason: "recent_activity", call again after retryAfterSeconds; with payment_may_be_in_flight, do not cancel, and wait for the payment webhook.
The same event can arrive more than once with the same id, so deduplicate on id as usual.
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": "7c1f4e2a-9b3d-4f60-8e5a-2d1c0b9a8f7e",
"vp_tx_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. |
vp_tx_id | string | null | The Von transaction id of the refunded charge, the same value its charge.succeeded carried. Match this refund to the charge on it. transaction_id is the processor's reference, as on every event. |
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, including this refund, on every connection. Failed refunds are never counted; a refund still processing on the same charge is. Compare this against original_charge_amount to decide "fully refunded". null only in the rare case our records could not be read when the event was built: treat it as unknown, never as zero, and look the payment up through the API rather than rejecting the event. |
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.
Switched on for you: like the dispute events, refund.failed is delivered to every active subscription whether or not you selected it. Listing it in enabledEvents is still accepted and changes nothing. Handle it (a 2xx is enough) rather than rejecting an event type you did not subscribe to.
A processor may report that outcome under more than one name. A refund that failed and a refund that was declined are both terminal, both mean the same thing to you (no money went back), and both arrive here. There is no separate declined event to subscribe to.
This is the failure twin of charge.refunded and deliberately mirrors its shape (including vp_tx_id, the Von transaction id of the charge), 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": "7c1f4e2a-9b3d-4f60-8e5a-2d1c0b9a8f7e",
"vp_tx_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. false when the payment was disputed: the buyer already has the money back through the chargeback, so do not retry or refund another way. |
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. 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 number the shopper and your store staff see: on Shopify the order name (for example #1001), elsewhere the platform's order number. A Shopify order created before 28 September 2026, or one recovered by an automatic retry, can carry the store's internal numeric id instead. |
order_status_url | string | null | Where the buyer can view the order in your store. A private link: anyone holding it can see that order, including the shopper's name and address, so show it only to that shopper and do not log, store or share it. |
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": "a4c8e1f3-7b2d-4d95-8e60-5c1b9a7d2f3e",
"amount": 1499,
"currency": "USD",
"payment_method_id": null
}
}
session_id is null for payment intents created outside the hosted-checkout flow. payment_method_id is the same saved-card id charge.succeeded carries, null when nothing was saved.
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": "e9b1d4a7-2c6f-4a13-9d58-7f3e0c2b1a6d",
"amount": 1499,
"currency": "USD",
"failure_reason": "Your card was declined.",
"failure_code": "card_declined",
"network_decline_code": "05",
"rule_code": null,
"retry": null
}
}
It carries the same decline fields as charge.failed, including retry: it is not final unless retry is { allowed: false, attempts_remaining: 0 }, and even then close the session before you cancel.
payment_intent.authorized
The card was authorised on a manual-capture payment and the money is held, not taken. It is the only notification that a hold exists, and it never fires for an automatic-capture payment.
{
"id": "vp_evt_live_7k6j5h4g3f",
"type": "payment_intent.authorized",
"created": 1728936005,
"livemode": true,
"merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce",
"data": {
"session_id": null,
"payment_intent_id": "vpi_live_ghi012p4...",
"transaction_id": "f2a9c6e1-3b7d-4e58-9a14-6d0c8b5e2f7a",
"amount": 1499,
"currency": "USD",
"capture_required": true
}
}
capture_required is always true, and amount is what may be captured, not what has been. There is no payment_method_id yet: a saved card is stored at capture and arrives on payment_intent.succeeded. Capture or void the hold before it lapses at the card's bank (roughly 7 days on Mastercard, American Express and Discover, about 5 days on a Visa merchant-initiated payment, and as little as 2 days for a card-present payment). Treat the shortest of those as your deadline unless you know the card brand.
It is sent once per payment, when the hold is placed (directly, or after the buyer completes 3-D Secure), for a manual-capture payment made through POST /v1/payment_intents, the hosted checkout page on a captureMethod: "manual" session, the embedded payment fields in elements mode, or a wallet payment on those fields. Some payment connections do not send it yet; GET /v1/payment_intents/{id} still shows status: "authorized" on those.
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": "c3f7a2d9-5e1b-4c84-a7d2-9b0e6f4a3c1d",
"amount": 1499,
"currency": "USD",
"cancellation_reason": "buyer_abandoned"
}
}
Session events
The session.* pair is about a hosted-checkout session only, never a payment made directly through POST /v1/payment_intents. Use them when you track the buyer's checkout session rather than the charge underneath it.
session.succeeded
The buyer completed the hosted checkout. Delivered once per session, even if the confirmation is retried.
{
"id": "vp_evt_live_4n3m2l1k9j",
"type": "session.succeeded",
"created": 1728936030,
"livemode": true,
"merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce",
"data": {
"session_id": "vp_cs_live_k7x9m2n4p3",
"payment_intent_id": null,
"transaction_id": "a4c8e1f3-7b2d-4d95-8e60-5c1b9a7d2f3e",
"amount": 1499,
"currency": "USD"
}
}
It carries the core of charge.succeeded without the card details, the saved card or vp_tx_id. Fulfil and refund against the matching charge.succeeded, which carries those. payment_intent_id is null when the session made no payment intent.
session.failed
A payment attempt on the session was declined. Sent once per declined attempt, so a buyer who tries two cards produces two (and, like every event, it can be redelivered with the same id). The fields match charge.failed (the decline reason, decline_code, action, decline_message, network_decline_code, rule_code and retry) without vp_tx_id. A session.failed does not mean the checkout failed unless retry is { allowed: false, attempts_remaining: 0 }; otherwise the buyer can still pay on it. Close the session before you cancel.
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 processor's reference for the disputed charge, the same value the charge.* events for that charge carried. Not a vp_tx_* id; see charge.succeeded. |
payment_intent_id | string | null | The disputed payment (vpi_*). null when the dispute cannot be matched to exactly one payment. |
session_id | string | null | The checkout session the payment belongs to. null when unmatched, or when the payment had no session. |
buyer_id | string | null | Your customer reference for the buyer. Not filled in on every payment connection, so it can be null even when a buyer was attached: match on payment_intent_id. |
amount | integer | null | The disputed amount, in minor units. |
currency | string | null | ISO 4217. |
Match a dispute to your order with payment_intent_id, never buyer_id. Treat null as unmatched and never guess: fall back to transaction_id, which every charge event also carries with the same value. There is no vp_tx_id on this family. 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 and refund.failed is delivered only if its exact type is in your subscription's enabledEvents, filtered per endpoint. The events documented here that you subscribe to are Charges (charge.succeeded, charge.failed), Refunds (charge.refunded; refund.failed arrives whether or not you list it), Payment Intents (payment_intent.succeeded, payment_intent.failed, payment_intent.cancelled, payment_intent.authorized), Sessions (session.succeeded, session.failed) and Order mirroring (mirror.order.created). To match them to one order, store every id a payment's first event carries (session_id, payment_intent_id, vp_tx_id) and match later events on any of them: which are null depends on how the payment was taken and on the event, and a refund made through the API carries no session_id.
Event names come from this catalog. A create or update whose list contains any name not in it, such as session.expired (there is no such event) or a misspelling like charge.suceeded, is refused with 400 validation_error ("verify the URL and that every event type is a recognized event"), even when the other names are valid. Nothing is stored and no signing secret is issued. The message does not name the bad entry, so check each name against this page. A name that fails the format (lowercase, dotted) is refused with 400 before the catalog is consulted.
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