Start and manage subscriptions from your code
This guide is for the developer who builds the checkout. You start a subscription with one field on the first charge. Von Payments then renews the card on the product's schedule, and tells your server about each change with an event.
Before you start:
- Subscriptions is switched on for your account, and you have a product. See Set up Subscriptions.
- Your checkout saves the card in the shopper's browser with Embedded Fields. A card you import from your server cannot start a subscription.
- You have a webhook endpoint and a secret API key. Every call on this page uses a secret key.
1. Read the product's terms
Read the terms before you show the order button, so the shopper sees exactly what they agree to:
curl https://checkout.vonpay.com/v1/subscription_products/vsprod_test_Hk29sLq0/terms \
-H "Authorization: Bearer $VON_PAY_SECRET_KEY"
{
"product": "vsprod_test_Hk29sLq0",
"product_ref": "pro-plan",
"disclosure": "$9.99 every 30 days until you cancel.",
"price_phrase": "$9.99 every 30 days",
"terms_version": "4f1c9a7e2b6d3085c1e4a9f27b5d8e03a6c2f19b4e7d0a58c3b6f2e91d4a7c05",
"first_amount": 999,
"amount": 999,
"currency": "USD",
"interval": "day",
"interval_count": 30
}
In the path, use the product's id (vsprod_live_… or vsprod_test_…) or its store product number, in your key's mode. An unknown, archived or other-mode product answers 404 subscription_product_not_found.
disclosureis our default sentence for the terms. It names no business and no way to cancel. Add your business name and how to cancel in your own words.price_phraseis the part your shown words must contain.terms_versionchanges whenever any of these numbers change.
With the Node SDK: vonpay.subscriptionProducts.terms("vsprod_test_Hk29sLq0"). With the Python SDK: client.subscription_products.terms("vsprod_test_Hk29sLq0").
2. Show the terms and record the agreement
Put your words next to the order button, and make sure they contain price_phrase. For example: "$9.99 every 30 days until you cancel. Cancel anytime from your account. Billed by Example Co."
Showing the terms, your business name and how to cancel is your responsibility. On the sign-up charge, send what the page showed in recurring.consent. We keep it with the subscription as evidence if a renewal is disputed.
recurring.consent field | Meaning |
|---|---|
method | checkbox: the shopper ticked a box to agree. checkout_terms: your terms were shown next to the order button, and pressing it was the agreement. |
accepted_at | When the shopper agreed, ISO 8601 with a time zone. |
disclosure_shown | The exact words shown next to the order button, up to 2,000 characters. Never put card details here. |
user_agent | The shopper's browser user agent. Kept as evidence only. |
Every field is optional, and the agreement never refuses a charge. When something is missing or looks wrong, the response carries a warning and the subscription starts anyway.
3. Start the subscription with the first charge
Save the card in the shopper's browser first, then send the charge from your server within 30 minutes. Add recurring with the product:
curl -X POST https://checkout.vonpay.com/v1/payment_intents \
-H "Authorization: Bearer $VON_PAY_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: signup_ord_42" \
-d '{
"payment_method": { "id": "vp_pmt_test_QAqnXEJF_TCum1jg" },
"buyer_id": "cust_1042",
"buyer_email": "buyer@example.com",
"buyer_ip_address": "203.0.113.7",
"recurring": {
"product": "vsprod_test_Hk29sLq0",
"consent": {
"method": "checkbox",
"accepted_at": "2026-10-31T14:03:11Z",
"disclosure_shown": "$9.99 every 30 days until you cancel. Cancel anytime from your account. Billed by Example Co.",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6)"
}
}
}'
You can leave out amount and currency: the first payment is the product's price, in the product's currency. Send the same Idempotency-Key on every retry of this charge, as for any charge.
The same call with the Node SDK:
import { VonPayCheckout } from "@vonpay/checkout-node";
const vonpay = new VonPayCheckout(process.env.VON_PAY_SECRET_KEY!);
const intent = await vonpay.paymentIntents.create(
{
paymentMethod: { id: "vp_pmt_test_QAqnXEJF_TCum1jg" },
buyerId: "cust_1042",
buyerEmail: "buyer@example.com",
buyerIpAddress: "203.0.113.7",
recurring: {
product: "vsprod_test_Hk29sLq0",
consent: {
method: "checkbox",
acceptedAt: "2026-10-31T14:03:11Z",
disclosureShown: "$9.99 every 30 days until you cancel. Cancel anytime from your account. Billed by Example Co.",
},
},
},
{ idempotencyKey: "signup_ord_42" },
);
console.log(intent.subscription?.id);
With the Python SDK:
import os
from vonpay.checkout import PaymentMethodRef, RecurringBlock, RecurringConsent, VonPayCheckout
client = VonPayCheckout(os.environ["VON_PAY_SECRET_KEY"])
intent = client.payment_intents.create(
payment_method=PaymentMethodRef(id="vp_pmt_test_QAqnXEJF_TCum1jg"),
buyer_id="cust_1042",
buyer_email="buyer@example.com",
buyer_ip_address="203.0.113.7",
recurring=RecurringBlock(
product="vsprod_test_Hk29sLq0",
consent=RecurringConsent(
method="checkbox",
accepted_at="2026-10-31T14:03:11Z",
disclosure_shown="$9.99 every 30 days until you cancel. Cancel anytime from your account. Billed by Example Co.",
),
),
idempotency_key="signup_ord_42",
)
print(intent.subscription.id if intent.subscription else None)
What we check before charging
Every rule is checked before the card is charged. A refusal charges nothing.
- Subscriptions is on for your account, and the product is ready. Otherwise
422recurring_not_enabledor409product_not_ready. - The card was just saved by this shopper.
payment_methodmust be a card (not a wallet) that the shopper saved in their own browser no more than 30 minutes earlier, and that was never used for a merchant-initiated charge. When the card is bound to a buyer, send that samebuyer_id. - The shopper is identified. Send
buyer_email(orbuyer.email) andbuyer_ip_address. - It is a plain card charge.
recurringcannot be combined withmit,network_token,payment_method_typeorgateway. - The shopper is not already subscribed. One live subscription per email and product. A new purchase never changes an existing subscription, unless it sends the
buyer_idthat subscription was created under. That restarts it on the new card.
Each refusal has its own code. See Subscription sign-up refusals.
The response
{
"id": "vpi_test_k3Jd8sLq2Pz9Xw4N",
"status": "succeeded",
"amount": 999,
"currency": "USD",
"recurring": {
"product": "vsprod_test_Hk29sLq0",
"product_ref": "pro-plan",
"interval": "day",
"interval_count": 30,
"amount": 999,
"first_amount": 999,
"currency": "USD",
"disclosure": "$9.99 every 30 days until you cancel."
},
"subscription": {
"id": "vsub_test_x7Q2mN9pR4tL8vWc",
"status": "active",
"next_bill_at": "2026-11-30T14:03:15.000Z"
}
}
recurringechoes the terms we resolved from the product.amountis the renewal price andfirst_amountis what this payment charged.subscriptionis the subscription this payment created, once it exists. It is normally created before the response is sent. It isnullwhen that takes longer, when the charge needs the shopper's 3-D Secure check, or on a manual-capture charge until you capture it. Thesubscription.createdevent then carries this payment'spayment_intent_id.- Both are on the create response and its replay only, and neither is on a declined charge.
With capture_method: "manual", the subscription is created when you capture the payment. An authorization you void never becomes a subscription.
Warnings about the agreement
When the shopper's agreement has a problem, the response carries warnings. The subscription starts either way.
code | Meaning |
|---|---|
recurring_consent_not_recorded | No recurring.consent was sent, so nothing was kept. |
recurring_disclosure_not_sent | No disclosure_shown, the main evidence in a dispute. |
recurring_disclosure_differs | disclosure_shown does not contain the terms this subscription runs on (price_phrase). Recorded as sent. |
recurring_consent_time_unusual | accepted_at is more than 2 minutes in the future, or more than 30 minutes before the charge. |
recurring_consent_text_not_stored | disclosure_shown or user_agent looked like a card number, or held characters that cannot be stored. It was not kept. |
4. Follow the subscription with events
Select the five subscription.* events on your webhook endpoint by name. An endpoint that selects "*" does not receive them. See Events for each event and its fields.
| Event | What to do |
|---|---|
subscription.created | Link subscription_id to your customer, by payment_intent_id or by your own ids in metadata. |
subscription.renewed | Fulfil one renewal per subscription and billing_cycle. |
subscription.payment_failed | Mark it past due. When status is failed, it has ended: no subscription.cancelled follows. |
subscription.cancelled | Stop access when your rules say so. A cancel never refunds. |
subscription.updated | Apply the new state. changed_fields says what changed. |
A renewal payment also sends the usual charge.* and payment_intent.* events, with the subscription_id. Treat those as a renewal of the subscription, never as a new order.
Events arrive in no guaranteed order. Deduplicate on the event id, and apply a state only from an event at least as new as the last one you applied (the envelope's created). The webhooks sample handles all five.
5. Manage subscriptions from your server
Find a customer's subscriptions with GET /v1/subscriptions (filter by buyer_id, email or status), and read one with GET /v1/subscriptions/{id}. A restricted key needs subscriptions.read to read and subscriptions.write to change.
| Change | Call | Allowed when the status is |
|---|---|---|
| Cancel now | POST /v1/subscriptions/{id}/cancel with a reason | active, past_due, paused, held |
| Cancel at the end of the paid period | POST /v1/subscriptions/{id}/cancel with reason and at_period_end: true | active |
| Withdraw a scheduled cancel | DELETE /v1/subscriptions/{id}/scheduled_cancel | any |
| Pause | POST /v1/subscriptions/{id}/pause, optional resumes_on (YYYY-MM-DD) | active, past_due |
| Resume | POST /v1/subscriptions/{id}/resume | paused |
| Skip the next renewal | POST /v1/subscriptions/{id}/skip | active |
| Move the next charge | POST /v1/subscriptions/{id}/next_bill_date with a date | active, past_due |
| Change how often it renews | POST /v1/subscriptions/{id}/interval with unit and count | active, paused |
| Set its renewal price | POST /v1/subscriptions/{id}/price with an amount | active, paused, past_due |
| Set or remove a discount | POST or DELETE /v1/subscriptions/{id}/discount | active, paused, past_due |
A date or interval change that bills the customer sooner or more often waits for the 7-day notice we email to the customer. A sooner date is dropped if that email cannot go out at least 7 days before it. A later date, or billing less often, applies at once. Each call's rules are in the reference. These calls are for your own systems: your customers' subscription page offers pause, resume and cancel.
import { VonPayCheckout } from "@vonpay/checkout-node";
const vonpay = new VonPayCheckout(process.env.VON_PAY_SECRET_KEY!);
const change = await vonpay.subscriptions.cancel(
"vsub_test_x7Q2mN9pR4tL8vWc",
{ reason: "too_expensive", atPeriodEnd: true },
{ idempotencyKey: "cancel_cust_1042_2026_10_31" },
);
console.log(change.changed, change.subscription.cancelAtPeriodEnd);
Send an Idempotency-Key on every change. A change without one is refused 400 idempotency_key_required. A retry with the same key and body changes nothing twice and answers with X-Idempotency-Replay: true. The same key on a different change answers 409 idempotency_key_reused.
Each change answers with a subscription_change object:
changedisfalsewhen the change was already in effect, for example a pause of a paused subscription.subscriptionis the subscription as it is now.warningslists anything to know, for examplerenewal_in_flightwhen you cancel while a renewal is being charged.
Try a change first with ?dry_run=true. It runs the change and rolls it back, then answers with what it would do, or the error it would return. Nothing is kept and no event is sent. A dry run needs no key.
Name the system making the change with the optional X-Vonpay-Actor-Label header, for example crm-sync. It comes back on the subscription.updated event as actor.label. Never put personal data in it.
A cancel never refunds. To refund a payment, call POST /v1/refunds. A refund never cancels a subscription.
6. Let the customer manage it
Give a customer you have already signed in a Manage subscription button. When they press it, ask for a one-time link and send them straight to it:
curl -X POST https://checkout.vonpay.com/v1/subscriptions/vsub_test_x7Q2mN9pR4tL8vWc/portal_links \
-H "Authorization: Bearer $VON_PAY_SECRET_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 7f9c2e1a-portal-press" \
-d '{ "return_url": "https://shop.example.com/account" }'
{
"url": "https://subscriptions.vonpay.com/r#vsc_3q2d0Jw8XyGmN1pL5sT7uV9wB4cE6fH8iK0mO2qR4sU",
"expires_at": "2026-10-31T14:05:00.000Z"
}
With the Node SDK: vonpay.subscriptions.createPortalLink(subscriptionId, { returnUrl }, { idempotencyKey }), which returns { url, expiresAt }.
- The link opens once, within five minutes. Create it on the button press, never ahead of time, and never email, log or store it. A second open, or one after
expires_at, shows the customer an expired-link page. - Keep the whole
url. The one-time code is after the#. - Send a fresh
Idempotency-Keyon every button press. Each call without one creates another live link. return_url(optional) is where the page's Back button goes. It must be on one of your own domains: a store you connected, or one added under Subscriptions › Settings › Your websites.- The page shows this subscription only. The customer can pause, resume or cancel it there.
When something goes wrong
| What happened | What you see | What to do |
|---|---|---|
| The sign-up was refused before the charge | A 4xx answer with a sign-up refusal code. Nothing was charged. | Fix what the code names, then send the charge again. |
| The first charge was declined | A declined payment, with no recurring and no subscription in the answer. No subscription exists. | Show the decline to the shopper. A new attempt is a new charge with its own Idempotency-Key (why), under the same rules. |
| The first charge needed 3-D Secure, or you capture it later | subscription is null in the answer. | Wait for subscription.created. Its payment_intent_id is this payment. |
| A renewal was declined | subscription.payment_failed with status: "past_due". next_bill_at is the retry date. | Nothing to send. We retry on your retry days and email the customer once per billing period. Decide from your own rules whether access continues meanwhile. |
| The retries ran out, or the decline cannot be fixed by a retry | The last subscription.payment_failed, with status: "failed" and cancel_reason retries_exhausted or hard_decline. No subscription.cancelled follows. | End access. To bill this customer again, they sign up again at your checkout. |
| A renewal or a retry was stopped before it reached the bank | subscription.payment_failed with status: "held" when a declined attempt caused the hold, otherwise subscription.updated with status: "held". next_bill_at is null. | Someone on your team fixes the reason in your dashboard's Exceptions. The release sends subscription.updated with status: "active". |
| The card's permission for repeat charges was withdrawn | subscription.cancelled with cancel_reason: "authorization_revoked". | End access. To bill this customer again, they sign up again at your checkout. |
| The customer cancelled, or scheduled a cancel | subscription.cancelled, or subscription.updated with cancel_at_period_end: true. actor.type is customer. | Stop access when your rules say. A scheduled cancel keeps the paid period. A cancel never refunds. |
Test it
Use your test sandbox and its test keys. Test products are vsprod_test_…, and test subscriptions are vsub_test_…. To send a sample of each event to your local endpoint, run vonpay checkout trigger subscription.created --url http://localhost:3000/webhooks (and the other four names).
What's next
- Subscriptions reference: the subscription object, every call, the events and the error codes.
- Webhook signature verification.