Skip to main content

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" }
}
FieldTypeNotes
idstringUnique per outbound event. Use this for idempotent processing — the same event ID is never delivered twice with different payloads.
typestringEvent type from the catalog below.
createdintegerUnix 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).
livemodebooleantrue 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_eventtrue, or absentPresent 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_idstringThe merchant the event belongs to (a UUID). On a multi-tenant platform integrator, route on this value.
dataobjectPer-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 fieldTypeDescription
session_idstring | nullThe session this charge belongs to. Null for direct-charge flows that do not go through /v1/sessions.
payment_intent_idstring | nullPayment 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_idstring | nullThe 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_idstring | nullThe 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.
amountintegerAmount charged, in minor units.
currencystringISO-4217 uppercase.
cardobject | nullPCI-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_codestring | nullNormalized 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_codestring | nullNormalized card security-code result — match, no_match, not_provided, or unavailable.
payment_method_idstring | nullThe 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}:

retryWhat it meansWhat 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 0Open 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.
nullThis 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 fieldTypeDescription
payment_intent_idstring | nullPayment intent (vpi_*) of the original charge.
vp_tx_idstring | nullThe 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_idstring | nullRefund record id (vpr_*) — identifies WHICH refund in a multi-partial sequence this event references.
amountinteger⚠️ 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_amountinteger | nullThis refund only, unambiguously, on every connection. null means the connection could not derive it — which is different from 0.
amount_refunded_totalinteger | nullCumulative 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.
reasonstring | nullRefund reason — customer_request / duplicate / fraudulent / merchant free-form.
is_partialboolean | nullWhether 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.
cardobject | nullPCI-safe { brand, last4 } of the refunded card (or null until card enrichment is active) — same shape as on charge.succeeded.
original_charge_amountinteger | nullFull charge total before any refund — lets you compute the remaining refundable balance without a round-trip.
Work out "fully refunded" from amount_refunded_total, never from is_partial or by summing amount

is_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 fieldTypeDescription
refund_idstring | nullRefund record id (vpr_*) — identifies which refund failed.
amountinteger | nullThe reversal amount that failed to move, in minor units. Nothing was transferred.
reasonstring | nullThe refund reason originally recorded, carried through from the refund itself.
reason_codestring | nullWhy it failed. Branch on this — see below.
retry_availableboolean | nullWhether 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_partialboolean | nullWhether 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_amountinteger | nullFull charge total before any refund.
cardobject | nullPCI-safe { brand, last4 } — same shape as on charge.refunded.

Branch on reason_code​

reason_codeWhat happenedWhat is safe to do
refund_declinedWe attempted the reversal and the provider refused it outright.Retrying unchanged will fail the same way. Find out why before retrying.
refund_unresolvedWe 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.

FieldTypeDescription
payment_intent_idstring | nullThe payment this order was mirrored from — use it to correlate with charge.succeeded.
order_numberstring | nullThe 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_urlstring | nullWhere 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:

FieldTypeDescription
dispute_idstring | nullThe dispute, as the processor identifies it.
transaction_idstring | nullThe 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_idstring | nullThe disputed payment (vpi_*). null when the dispute cannot be matched to exactly one payment.
session_idstring | nullThe checkout session the payment belongs to. null when unmatched, or when the payment had no session.
buyer_idstring | nullYour 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.
amountinteger | nullThe disputed amount, in minor units.
currencystring | nullISO 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.