Skip to main content

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: a SessionRetry with allowed and attempts_remaining, whether the session accepts another card after a decline (an elements session 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. None when it does not apply.
  • last_payment_error: a SessionLastPaymentError with code ("card_declined" or "payment_failed", open set) and decline_code (the payment intent's failure_code values, or None).
  • amount_refunded, remaining_refundable and refundable: the refund totals, None together when they could not be read or the session has no single payment. None is 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_code and cvv_result_code: the address and security-code results. None when 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_refundable and refundable: the refund totals, None together when they could not be read. None is not zero: read again or call refunds.list(). refundable is False until the charge settles.
  • next_action while the payment is requires_action: the challenge link is pi.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; check issued_at first, 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 payment

The 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_event verification failures (signature mismatch, stale timestamp, malformed header)

It does not fire on:

  • verify_signature / verify_return_signature — these return bool, 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.