Python SDK
Typed Python client for the Von Payments Checkout API, published as vonpay-checkout on PyPI.
Requirements: Python 3.9+, httpx
Install
pip install vonpay-checkout
Pin the major you tested against — minor and patch releases are additive under semver, though a minor bump may add options or change defaults. A major bump is breaking and comes with an upgrade section: see Upgrading to 2.0.0.
Initialize
from vonpay.checkout import VonPayCheckout, VonPayError
client = VonPayCheckout("vp_sk_test_...", api_version="2026-04-14")
The api_version parameter pins the API version for all requests made by this client instance. See API Versioning for details.
Sessions
Create a session
session = client.sessions.create(
amount=1499,
currency="USD",
country="US",
)
print(session.id) # "vp_cs_test_abc123"
print(session.checkout_url) # "https://checkout.vonpay.com/checkout?session=..."
Returns a CheckoutSession object.
Get a session
status = client.sessions.get("vp_cs_test_abc123")
print(status.status) # "succeeded"
print(status.amount) # 1499
Returns a SessionStatus object. Requires a secret key.
From 3.6.0 the session read also carries:
retry: aSessionRetrywithallowedandattempts_remaining, whether the session accepts another card after a decline (anelementssession charging at submit, on an account with retry enabled). It is the only place to learn this when the decline came after the bank's check.Nonewhen it does not apply.last_payment_error: aSessionLastPaymentErrorwithcode("card_declined"or"payment_failed", open set) anddecline_code(the payment intent'sfailure_codevalues, orNone).amount_refunded,remaining_refundableandrefundable: the refund totals,Nonetogether when they could not be read or the session has no single payment.Noneis not zero.
On an expired session, expired_from of "processing" can later change to "failed" once the provider confirms an abandoned bank check authorized nothing, so a "processing" you read once is not final. The rules are on The session object.
Change an open session's total
from vonpay.checkout import SessionNotModifiableError
try:
session = client.sessions.update("vp_cs_test_abc123", amount=5498)
except SessionNotModifiableError as e:
# Nothing was changed. e.reason is not_open, integration_mode,
# setup_session, store_order or in_page_bank_check; e.session has its state.
raise
Only elements sessions can change their total. currency and line_items are optional keyword arguments. Added in 2.11.0; the rules are on Change an open session's total.
Close a session
import time
from vonpay.checkout import SessionNotExpirableError
try:
client.sessions.expire("vp_cs_test_abc123")
except SessionNotExpirableError as err:
if err.reason != "recent_activity":
# Nothing changed, and a payment may exist on it. Do not charge another way.
raise
# A declined session that changed recently: ask again after the wait.
# The SDK never repeats the call itself.
time.sleep(err.retry_after_seconds or 1)
client.sessions.expire("vp_cs_test_abc123")
From 3.5.0 the error carries reason: "recent_activity" (call again after retry_after_seconds), "payment_may_be_in_flight", or "unknown" for a value the SDK does not recognise, handled like "payment_may_be_in_flight". raw_reason keeps the value as sent. A closed session's expired_from can be "failed".
sessions.create() takes buyer_contact (phone, shipping and billing address) from 3.5.0, with the same rules as buyerContact on Create a session: secret key only, never returned on the read, personal data you must not log.
Closes an open, unpaid session so no payment can start on it afterwards. See Close a session so you can charge another way.
Validate (dry run)
result = client.sessions.validate(
amount=1499,
currency="USD",
)
# Validates parameters without creating a session
Payment intents
Read a payment intent
pi = client.payment_intents.retrieve(stored_intent_id)
print(pi.status) # "succeeded"
if pi.cvv_result_code == "no_match":
... # a real mismatch
Returns a PaymentIntentRead. Requires a secret key. Pass the id you stored when you created the intent, never one read out of a return URL. From 3.6.0 it also carries:
avs_result_codeandcvv_result_code: the address and security-code results.Nonewhen nothing was checked or no result came back."unavailable"and"not_supported"(address) or"not_provided"and"unavailable"(security code) mean the check did not run, not that it failed. This server read is where a payment taken in the browser gets its result, because the browser never receives it. Keep the values on your server.amount_refunded,remaining_refundableandrefundable: the refund totals,Nonetogether when they could not be read.Noneis not zero: read again or callrefunds.list().refundableis False until the charge settles.next_actionwhile the payment isrequires_action: the challenge link ispi.next_action["redirect_to_url"]["url"], and["issued_at"]beside it says when it was issued. If your server lost the create response, send the buyer to the link from here; checkissued_atfirst, since the payment provider decides how long a link stays usable. See 3-D Secure.
Refunds
List a payment's refunds
page = client.refunds.list(
transaction=session.vp_tx_id, # or payment_intent="vpi_...", exactly one
limit=25,
starting_after=cursor,
)
page.data # newest first, every status
page.amount_refunded # succeeded + still in progress, across all pages
page.remaining_refundable # what a refund with no amount would refund now
page.refundable # False until the charge settles, and when disputed or fully refunded
page.next_cursor # pass back as starting_after while has_more; None on the last page
Added in 3.6.0; maps to GET /v1/refunds. Requires a secret key. Pass exactly one of payment_intent or transaction; both or neither raises ValueError before any request. Check each refund's own status, since a requested refund is still in progress and can still fail. Each listed Refund carries idempotency_key and created_at. It never moves money and is retried like any other read. The fields are on List a payment's refunds.
A refunds.create() answer now carries idempotent: True when it replays an earlier request with the same idempotency_key, so no new refund was issued.
Webhooks
Verify signature
Verify the HMAC-SHA256 signature on an incoming webhook request. The webhook secret is per-endpoint (whsec_*), minted when you register the endpoint at /dashboard/developers/webhooks.
is_valid = client.webhooks.verify_signature(
payload=request_body,
signature_header=request.headers["x-vonpay-signature"],
secret=os.environ["VON_PAY_WEBHOOK_SECRET"], # whsec_*
)
Construct event
Parse and verify a webhook payload into a typed event object. Enforces the asymmetric replay window (5 min past / 30 sec future).
event = client.webhooks.construct_event(
payload=request_body,
signature_header=request.headers["x-vonpay-signature"],
secret=os.environ["VON_PAY_WEBHOOK_SECRET"], # whsec_*
)
print(event.type) # "charge.succeeded"
print(event.data["vp_tx_id"]) # "vp_tx_test_9f2nd..." — the Von transaction id; reconcile and refund on this
event.data is a plain dict, handed through as sent. On a Send test event delivery the event carries test_event: True: check it first, before any lookup or write, and return 2xx without doing anything else.
charge.failed, payment_intent.failed and session.failed carry event.data.get("retry"): {"allowed": bool, "attempts_remaining": int} or None. A failure event is sent for every declined attempt, so one is not final on its own. allowed True means the session was re-opened for another card (keep the order open); allowed False with attempts_remaining 0 is final for this checkout. A missing key means unknown, never final. See A failed charge is not always the end of the checkout.
Webhook management
Read-only access to your registered webhook endpoints and stored event records. These methods all require a secret key (vp_sk_*) — publishable keys are rejected with 403. The wire format is camelCase end-to-end and parsed into typed dataclasses.
List webhook subscriptions
subs = client.webhook_subscriptions.list(
limit=20, # optional
starting_after="b6f2c1a0-3e4d-4c8b-9a1f-2d5e7c9b0a13", # optional cursor
)
for sub in subs.data:
print(sub.id, sub.url, sub.status)
print(subs.has_more) # True if more pages remain
Results are newest-first. To page, pass the last item's id as starting_after. A cursor that doesn't reference a subscription owned by your merchant raises a 400 validation_error (pagination is not silently restarted from the top).
Returns a WebhookSubscriptionsList object with object, data (a list of WebhookSubscription), has_more, and url. Requires a secret key.
Retrieve a webhook subscription
sub = client.webhook_subscriptions.retrieve("b6f2c1a0-3e4d-4c8b-9a1f-2d5e7c9b0a13")
print(sub.url) # the registered endpoint URL
print(sub.enabled_events) # ["charge.succeeded", ...]
print(sub.status) # "active" | "paused" | "disabled"
Returns a WebhookSubscription object. A cross-merchant or soft-deleted id returns 404 (opaque — never 403). The signing secret is never present on a read response. Requires a secret key.
Send a test event
Sends one signed test event to the endpoint through the real delivery path and returns the outcome in the same call. Added in 3.6.0. Requires a secret key.
outcome = client.webhook_subscriptions.send_test_event(
sub.id,
event_type="charge.succeeded",
session_id="vp_cs_test_abc123", # optional
)
print(outcome.delivered) # True when the endpoint answered 2xx
print(outcome.response_status) # the endpoint's HTTP status, or None if nothing answered
print(outcome.error) # why it was not delivered; None when delivered
Returns a SendTestEventOutcome with delivered, response_status, delivery_attempt_id, signature_preview and error. delivered False with a response_status is a successful test of an endpoint that answered non-2xx. If the endpoint could not be reached or timed out, the call raises 502 webhook_test_delivery_failed and the SDK does not repeat it: check the URL and send again. A 429 or 503 is retried as usual.
The delivered event carries test_event: True, the only marker that it is synthetic. Your handler must check it before touching an order: with session_id, the payload carries that session's real ids and will match a real order.
Retrieve a stored webhook event
record = client.webhook_events.retrieve("vp_wh_5x9k2m4n")
print(record.type) # "charge.succeeded"
print(record.processed) # True | False
print(record.retry_count) # int
Returns a WebhookEventRecord — our stored record of an event received from the processor, keyed by the id in the dashboard's Events view (not a delivered payload's vp_evt_* id, which webhooks.construct_event parses). Requires a secret key.
Confirming a return redirect
When the buyer is redirected back to your success_url, read the outcome from the API — the redirect itself only tells you they came back.
client = VonPayCheckout(os.environ["VON_PAY_SECRET_KEY"])
outcome = client.sessions.confirm_return(dict(request.args))
if outcome.paid:
... # show the confirmation page
elif outcome.reason == "still_pending":
... # IN FLIGHT, not declined — "confirming your payment", never a failure
else:
... # not succeeded
sessions.confirm_return(params) verifies the return and reads the server-side
status in one call, returning a ReturnOutcome with paid, signature_valid,
status and reason. Branch on paid — it is decided by the authenticated session
read, never by the redirect URL.
⚠️ Do not pass the signing secret. The secret argument is optional and returns
are signed platform-wide, so a per-merchant ss_* can never match. Leaving it out is
the normal path: signature_valid then reads None, which means "not checked" — a
healthy value, not a failure. False would mean checked and it failed.
⚠️ paid being true is not "safe to fulfil". The status keeps reading
succeeded on every replay of the same URL, so record which session IDs you have
already fulfilled; and on a captureMethod: "manual" session succeeded means authorised and held, not paid. Fulfil the order from the charge.succeeded webhook rather than
here — a buyer who pays and closes the tab never loads this page. See
Handle the Return.
verify_return_signature is not how you confirm a paymentThe SDK still exposes this static method, but a signature check answers "was this message altered?" — not "did the buyer pay?" A declined payment carries an equally valid signature, so it must never gate a confirmation page or an order. Use confirm_return above.
Signature verification belongs on webhooks, where each endpoint has its own whsec_* secret — see Webhooks.
Health Check
health = client.health()
print(health.status) # "ok" — the only value this call can return
print(health.latency_ms) # float — measured dependency latency (ms)
print(health.version) # always "" — the endpoint sends no version field
Returns a HealthStatus dataclass with status, latency_ms, and version.
Branch on neither. status is always "ok" — this call sends no query
parameters, so it gets the liveness ping; "healthy"/"degraded" need the deep
check, which health() cannot request (call ?deep=true directly), and
"error" is a 503, which raises. version is always "": the endpoint sends
no such field. The signal is whether the call raised.
Error Handling
All API errors raise VonPayError with structured fields for programmatic handling.
from vonpay.checkout import VonPayCheckout, VonPayError
try:
session = client.sessions.create(amount=-1, currency="USD")
except VonPayError as e:
print(e.status) # 400
print(e.code) # "validation_invalid_amount"
print(e.fix) # "Amount must be a positive integer in minor units (cents). 1499 = $14.99"
print(e.docs) # "https://docs.vonpay.com/integration/create-session#amount-format"
Buyer profiles
New in 2.0.0. A buyer profile is a stored shopper record you can attach payments to and look up later.
buyer = client.buyers.upsert(external_id="cust_4471", email="ada@example.com")
client.buyers.retrieve(buyer.id)
client.buyers.find(email="ada@example.com") # None when not found
client.buyers.update(buyer.id, phone="+15551234567")
Five methods: upsert, retrieve, find, update, erase_email. find
returns None rather than raising when there is no match.
Erasing an email is its own method, on purpose
⛔ This is the one part of the buyers API where the SDK deliberately refuses to
do what the endpoint allows. PATCH /v1/buyers/{id} treats email: null as a
permanent erase — and a routine update built from a half-populated object can
send that null without anyone intending it.
So the SDK separates "not supplied" from "explicitly None": update() raises
on email=None, and erase_email() is the only path that sends it.
client.buyers.erase_email(buyer.id) # the only way to erase
⛔ erase_email is not a complete deletion and must never be described to a
shopper as one. It clears the address on the profile. Copies elsewhere keep
their own retention rules and this call does not touch them.
⚠ And a success does not confirm the provider's copy is gone. We ask your payment provider to drop theirs, but that request is dispatched after we answer you and reports no failure back. For a buyer you only ever identified by email, the retraction cannot be performed at all — the key it needs was derived from the address just erased.
Buyers is the full model — which copies survive, why that list is examples rather than an inventory, and what to do when you are answering a formal erasure request. Read it before you build on this.
Upgrading to 3.6.0
Check your code before upgrading: saved-card details can now be None at runtime. When the payment provider's details for a saved card cannot be read, the card is still saved and usable and the API returns each detail as null. Before 3.6.0 the SDK filled those in with "" or 0; now token.card.brand, .last4, .exp_month and .exp_year are None, as is a missing card. Code that uses them directly, such as token.card.last4.upper() or token.card.exp_year + 1, raises on such a card. Guard each read:
label = token.card.last4 or "unknown"
Cards whose details were read are unaffected. If you match exhaustively on resolved_descriptor.reason, add "invalid_for_provider": the charge was sent without your configured descriptor because it breaks a provider limit. See Statement descriptors.
Upgrading to 3.0.0
Two breaking changes. Nothing was removed or renamed.
Pass idempotency_key to void() to keep automatic retries. A void now follows the same rule as capture and refunds: after a timeout or a 500/502/503/504 it is retried only when you passed a key. Without one, the first error is raised with retry_withheld=True. A void releases the hold for good, and an unclear failure does not mean it was not applied.
vonpay.payment_intents.void(payment_intent_id, idempotency_key=f"order_{order_id}_void")
A redirect from the API is now an error. The API never redirects, so a 3xx means something else answered: a proxy, a mistyped base_url, or a hijacked connection. The SDK no longer follows it and raises unexpected_redirect with retryable false and a new next_action value, check_configuration. Do not retry: check that base_url is https://checkout.vonpay.com with no path, then any proxy and the network. If you match exhaustively on error codes or next actions, add both values.
Upgrading to 2.0.0
One breaking change, and it is a type change rather than a behaviour change.
rejectReason dropped five values it could never receive. not_authorized,
already_captured, already_voided, already_refunded and
amount_exceeds_remaining are gone; the server never sent any of them, so every
comparison against one was already dead code. Those comparisons now fail to
compile. Delete them.
⭐ The value worth adding a branch for is concurrent_update — another
capture or void is in flight for the same payment right now. Before 2.0.0 the SDK
replaced any value it did not recognise with nothing at all, so a branch checking
for a race never fired and a payment still in flight was read as terminal.
That is how a live payment gets cancelled or refunded. Treat it as "unknown
outcome, read again", never as an end state.
The full list and what each value means is on Error codes.
Error reporting
The SDK accepts an optional error_reporter callback so integrators can pipe SDK failures into their own observability stack (Sentry, Datadog, custom logger). Your reporter is invoked synchronously, fire-and-forget; if it raises, the SDK swallows it (with a logging.warning on the vonpay.checkout logger) and continues.
When it fires
- API request failures: non-retryable 4xx, retry-exhaustion on 5xx, network/timeout errors after retry exhaustion
webhooks.construct_eventverification failures (signature mismatch, stale timestamp, malformed header)
It does not fire on:
verify_signature/verify_return_signature— these returnbool, never raise- The constructor's invalid-key-prefix
ValueError— that's a dev-time error before the reporter is wired
Callback shape
from vonpay.checkout import ErrorReporter, ErrorReporterContext, VonPayError
def reporter(err: Exception, ctx: ErrorReporterContext) -> None:
# err is VonPayError or another Exception subclass
# ctx fields:
# method: str — "sessions.create" / "webhooks.construct_event" / etc.
# sdk_version: str
# url: str | None — origin + path, no query string (no PII via params)
# status: int | None — HTTP status if from API response
# request_id: str | None — X-Request-Id for correlation
# code: str | None — server error code
# attempt: int | None — 0-indexed retry attempt
...
Sentry example
import sentry_sdk
from vonpay.checkout import VonPayCheckout
def report_to_sentry(err, ctx):
sentry_sdk.capture_exception(
err,
tags={"sdk": "vonpay-python", "method": ctx.method, "code": ctx.code},
contexts={"vonpay": ctx.__dict__},
)
client = VonPayCheckout(
api_key=os.environ["VON_PAY_SECRET_KEY"],
error_reporter=report_to_sentry,
)
Logging example
import logging
from vonpay.checkout import VonPayCheckout
log = logging.getLogger("myapp")
client = VonPayCheckout(
api_key=os.environ["VON_PAY_SECRET_KEY"],
error_reporter=lambda err, ctx: log.error(
"vonpay sdk error", extra={"err": str(err), "ctx": ctx.__dict__}
),
)
error_reporter is opt-in and additive — if you don't configure it, errors still propagate via raise and nothing else changes.
Charges without an idempotency key
From the 28 October 2026 cut-off the API can refuse a charge that carries no Idempotency-Key with 400 idempotency_key_required, before anything is charged (Idempotency). From 3.2.0 the SDK emits one FutureWarning per process the first time payment_intents.create() is called without idempotency_key. The charge is still sent. An empty string counts as no key. VONPAY_QUIET=1 silences the warning.
If you run with warnings as errors (python -W error, PYTHONWARNINGS=error or pytest's filterwarnings = error), a keyless payment_intents.create() raises FutureWarning locally, before anything is sent. Nothing is charged, but the call fails. Pass idempotency_key on every charge before you upgrade.
vonpay.payment_intents.create(..., idempotency_key=f"order_{order_id}_create")
Auto-Retry
The SDK automatically retries on 429 (rate limited) and 5xx (server error) responses using exponential backoff. Default is 2 retries; configure with the max_retries constructor argument.
⛔ Money-moving calls are the exception. On a charge, capture, void (from 3.0.0) or refund the SDK
retries an ambiguous response only when you passed an idempotency_key.
Without one it stops and raises rather than repeating the call, because a 5xx
or a timeout there means the provider was reached and the answer was lost —
repeating it can charge the buyer twice. 429 still retries without a key
(nothing reached the handler), and non-money writes are unchanged.
From 3.3.0 there is one case a key does not cover: when the 5xx reply to a money call (payment_intents.create, capture, void, refunds.create) cannot be read, because it was cut off or replaced by a proxy's HTML error page, the SDK stops after the first attempt with or without a key. The error carries retry_withheld=True; read the payment intent back before repeating anything.
In 2.11.0 and later, a 502 payment_outcome_unknown is never retried, key or no key: the provider accepted the capture or void and only the record of it failed. The raised error carries capture_outcome or void_outcome set to "unknown". Read the payment intent back before doing anything else.
Sample app
Clone-and-run reference integration using this SDK lives in Von-Payments/vonpay-samples:
checkout-flask— Flask hosted checkout with signed return verification + webhook handler
Ships with .env.example, a per-sample README, and pinned to a known-working SDK version.