Skip to main content

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.

  • disclosure is 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_phrase is the part your shown words must contain.
  • terms_version changes 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 fieldMeaning
methodcheckbox: 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_atWhen the shopper agreed, ISO 8601 with a time zone.
disclosure_shownThe exact words shown next to the order button, up to 2,000 characters. Never put card details here.
user_agentThe 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 422 recurring_not_enabled or 409 product_not_ready.
  • The card was just saved by this shopper. payment_method must 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 same buyer_id.
  • The shopper is identified. Send buyer_email (or buyer.email) and buyer_ip_address.
  • It is a plain card charge. recurring cannot be combined with mit, network_token, payment_method_type or gateway.
  • 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_id that 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"
}
}
  • recurring echoes the terms we resolved from the product. amount is the renewal price and first_amount is what this payment charged.
  • subscription is the subscription this payment created, once it exists. It is normally created before the response is sent. It is null when that takes longer, when the charge needs the shopper's 3-D Secure check, or on a manual-capture charge until you capture it. The subscription.created event then carries this payment's payment_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.

codeMeaning
recurring_consent_not_recordedNo recurring.consent was sent, so nothing was kept.
recurring_disclosure_not_sentNo disclosure_shown, the main evidence in a dispute.
recurring_disclosure_differsdisclosure_shown does not contain the terms this subscription runs on (price_phrase). Recorded as sent.
recurring_consent_time_unusualaccepted_at is more than 2 minutes in the future, or more than 30 minutes before the charge.
recurring_consent_text_not_storeddisclosure_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.

EventWhat to do
subscription.createdLink subscription_id to your customer, by payment_intent_id or by your own ids in metadata.
subscription.renewedFulfil one renewal per subscription and billing_cycle.
subscription.payment_failedMark it past due. When status is failed, it has ended: no subscription.cancelled follows.
subscription.cancelledStop access when your rules say so. A cancel never refunds.
subscription.updatedApply 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.

ChangeCallAllowed when the status is
Cancel nowPOST /v1/subscriptions/{id}/cancel with a reasonactive, past_due, paused, held
Cancel at the end of the paid periodPOST /v1/subscriptions/{id}/cancel with reason and at_period_end: trueactive
Withdraw a scheduled cancelDELETE /v1/subscriptions/{id}/scheduled_cancelany
PausePOST /v1/subscriptions/{id}/pause, optional resumes_on (YYYY-MM-DD)active, past_due
ResumePOST /v1/subscriptions/{id}/resumepaused
Skip the next renewalPOST /v1/subscriptions/{id}/skipactive
Move the next chargePOST /v1/subscriptions/{id}/next_bill_date with a dateactive, past_due
Change how often it renewsPOST /v1/subscriptions/{id}/interval with unit and countactive, paused
Set its renewal pricePOST /v1/subscriptions/{id}/price with an amountactive, paused, past_due
Set or remove a discountPOST or DELETE /v1/subscriptions/{id}/discountactive, 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:

  • changed is false when the change was already in effect, for example a pause of a paused subscription.
  • subscription is the subscription as it is now.
  • warnings lists anything to know, for example renewal_in_flight when 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.

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-Key on 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 happenedWhat you seeWhat to do
The sign-up was refused before the chargeA 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 declinedA 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 latersubscription is null in the answer.Wait for subscription.created. Its payment_intent_id is this payment.
A renewal was declinedsubscription.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 retryThe 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 banksubscription.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 withdrawnsubscription.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 cancelsubscription.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​