Subscriptions and recurring billing
Charge customers every day, week, month or year. Prices are in dollars; each invoice is paid in bolívares at the day’s rate, by link or by automatic debit. VEX Pay issues the invoices, emails your customers, retries and cancels for you.
What you get
Subscriptions turn a one-time sale into recurring revenue without building a billing system. You define the plan once; VEX Pay creates an invoice every period, sends it to your customer in your name, collects it, follows up when it is not paid, and tells your server about every step with webhooks.
- Prices in dollars, paid in bolívares. A plan costs
10.00USD; the customer pays the Bs equivalent at your account’s current rate (BCV or market, whichever your account uses) at the moment they pay, so your revenue does not lose value between periods. - Two ways to collect.
send_invoiceemails a payment link every period (Pago Móvil, Débito Inmediato, card, stablecoins or Colombian pesos — whatever your account accepts).charge_automaticallydebits the customer’s bank account by domiciliación: they authorize once, then pay nothing by hand. See Automatic charging. - A hosted invoice page. Every invoice has its own payment page; you never handle payment screens for renewals.
- Emails in Spanish, sent in your name. Invoices, reminders, receipts and charge notices go out as “<your business> vía VEX Pay”, with replies going to your business email.
- Dunning built in. Every invoice gets daily collection attempts: the first when it is issued, then one a day (3 in total by default, up to 15). If it is still unpaid after the last one, the subscription is canceled — and comes back on its own if the customer pays that invoice late.
- Trials, cancel at period end, metadata and cursor pagination, with an API shaped like the billing APIs you already know.
| Good fit | Example |
|---|---|
| Memberships | A gym charging $25 a month |
| Classes and tutoring | A school charging $40 every 4 weeks |
| Software and media | A SaaS plan at $15 a month, or $150 a year |
| Services | Weekly cleaning, monthly maintenance, internet service |
How it works
| Object | What it is | Id |
|---|---|---|
| Product | What you sell (you may already have it for payment links) | UUID |
| Price | How much and how often: a USD amount and an interval (day, week, month, year) × intervalCount | price_… |
| Customer | Who pays: name, email, and optionally phone, cédula/RIF and bank | cus_… |
| Subscription | A customer on a price, with a collection method and status | sub_… |
| Invoice | What is owed for one period, with its hosted payment page | in_… |
| Setup session | A 24-hour link where the customer authorizes domiciliación | seti_… |
| Payment method | The customer’s authorized domiciliación | pm_… |
- You create the subscription
The first period starts now (or when the trial ends). VEX Pay issues the first invoice immediately.
- The invoice is collected
With
send_invoicethe customer receives an email with the payment link. Withcharge_automaticallyVEX Pay debits their account. - It is paid
The invoice becomes
paid, the money lands in your balance like any other payment, the customer gets a receipt, and you getinvoice.paid. - The next period starts
At the end of each period the next one starts and a new invoice is issued. Periods are counted from the start date, so a subscription that starts on the 31st renews on the last day of shorter months.
Quickstart: your first subscription
Use your Test key: nothing is charged, and emails are only delivered to your own business-profile email (see Test mode below). Call the API from your server with curl or one of the SDKs.
curl --request POST "${API_URL}/v1/products" \
--header "content-type: application/json" \
--header "x-api-key: ${VEXPAY_API_KEY}" \
--data '{ "name": "Plan Pro", "priceUsd": "25.00" }'
# → { "id": "PRODUCT_ID", ... }curl --request POST "${API_URL}/v1/prices" \
--header "content-type: application/json" \
--header "x-api-key: ${VEXPAY_API_KEY}" \
--data '{
"product": "PRODUCT_ID",
"unitAmountUsd": 25,
"recurring": { "interval": "month", "intervalCount": 1 },
"nickname": "Pro mensual"
}'
# → { "id": "price_…", "unitAmountUsd": "25.00", "currency": "USD", ... }curl --request POST "${API_URL}/v1/customers" \
--header "content-type: application/json" \
--header "x-api-key: ${VEXPAY_API_KEY}" \
--data '{
"name": "Ana Pérez",
"email": "ana@example.com",
"phone": "04141234567",
"idNumber": "V12345678",
"metadata": { "userId": "u_981" }
}'
# → { "id": "cus_…", ... }curl --request POST "${API_URL}/v1/subscriptions" \
--header "content-type: application/json" \
--header "x-api-key: ${VEXPAY_API_KEY}" \
--data '{
"customer": "cus_…",
"price": "price_…",
"collectionMethod": "send_invoice"
}'const product = await vexpay.products.create({ name: 'Plan Pro', priceUsd: '25.00' });
const price = await vexpay.prices.create({
product: product.id,
unitAmountUsd: 25,
recurring: { interval: 'month', intervalCount: 1 },
nickname: 'Pro mensual',
});
const customer = await vexpay.customers.create({
name: 'Ana Pérez',
email: 'ana@example.com',
metadata: { userId: 'u_981' },
});
const subscription = await vexpay.subscriptions.create({
customer: customer.id,
price: price.id,
collectionMethod: 'send_invoice',
});product = vexpay.products.create({"name": "Plan Pro", "priceUsd": "25.00"})
price = vexpay.prices.create({
"product": product.id,
"unitAmountUsd": 25,
"recurring": {"interval": "month", "intervalCount": 1},
"nickname": "Pro mensual",
})
customer = vexpay.customers.create({
"name": "Ana Pérez",
"email": "ana@example.com",
"metadata": {"userId": "u_981"},
})
subscription = vexpay.subscriptions.create({
"customer": customer.id,
"price": price.id,
"collectionMethod": "send_invoice",
})$product = $vexpay->products->create(['name' => 'Plan Pro', 'priceUsd' => '25.00']);
$price = $vexpay->prices->create([
'product' => $product->id,
'unitAmountUsd' => 25,
'recurring' => ['interval' => 'month', 'intervalCount' => 1],
'nickname' => 'Pro mensual',
]);
$customer = $vexpay->customers->create([
'name' => 'Ana Pérez',
'email' => 'ana@example.com',
'metadata' => ['userId' => 'u_981'],
]);
$subscription = $vexpay->subscriptions->create([
'customer' => $customer->id,
'price' => $price->id,
'collectionMethod' => 'send_invoice',
]);{
"id": "sub_3fd84b2d9ad14f74a2d90f2f56984db5",
"object": "subscription",
"customer": "cus_…",
"price": { "id": "price_…", "unitAmountUsd": "25.00", "currency": "USD", "recurring": { "interval": "month", "intervalCount": 1 }, ... },
"status": "active",
"collectionMethod": "send_invoice",
"collectionAttempts": null,
"paymentMethod": null,
"currentPeriodStart": "2026-10-21T14:00:00.000Z",
"currentPeriodEnd": "2026-11-21T14:00:00.000Z",
"trialStart": null,
"trialEnd": null,
"cancelAtPeriodEnd": false,
"canceledAt": null,
"cancellationReason": null,
"latestInvoice": "in_…",
"methods": [],
"metadata": {},
"createdAt": "2026-10-21T14:00:00.000Z",
"livemode": false
}The first invoice exists already: read it with GET /v1/invoices?subscription=sub_… and open its hostedInvoiceUrl to see what your customer sees. In live mode Ana also receives it by email; in test mode the email is only delivered if the customer’s email is your business-profile email.
Prices
A price belongs to a product and fixes the amount and the cycle. The same product can have several prices — monthly and yearly, for example.
| Field | Rules |
|---|---|
unitAmountUsd | Dollars, up to 2 decimals, at least your account’s minimum charge (otherwise 422 amount_below_minimum) and at most 100,000. |
recurring.interval | day, week, month or year. |
recurring.intervalCount | Default 1. The whole cycle can be at most one year: up to 365 days, 52 weeks, 12 months or 1 year. Every 2 weeks is { "interval": "week", "intervalCount": 2 }. |
nickname, metadata | Optional; your own labels (metadata: up to 20 string pairs). |
Amount, cycle and product never change once the price exists — changing them answers 400. To raise a price, create a new price and archive the old one with POST /v1/prices/:id and { "active": false }. Existing subscriptions keep their price; new ones cannot use an archived price (price_inactive).
Customers
| Field | Why it matters |
|---|---|
email | Required for send_invoice — it is where invoices go. emailStatus becomes undeliverable if the address bounces; update the email to try again. |
phone, idNumber, bankCode | Optional. They prefill the domiciliación authorization page, so the customer types less. |
name, metadata | Shown in your portal; use metadata to store your own user id. |
Update with POST /v1/customers/:id (send null to clear a field). DELETE /v1/customers/:id removes a customer with no live subscription; otherwise it answers 409 customer_has_active_subscriptions. Find a customer by email with GET /v1/customers?email=ana@example.com.
Subscriptions
| Field | Meaning |
|---|---|
customer, price | Required. The price must be active. |
collectionMethod | send_invoice (default) or charge_automatically. |
collectionAttempts | 1–15 daily attempts per invoice before the subscription is canceled. Omit (or null) to use your account default, 3 unless you change it in Portal → Billing → Settings. |
paymentMethod | charge_automatically only: the pm_… to debit. Default: the customer’s most recent domiciliación that is not inactive. |
trialPeriodDays or trialEnd | A free trial: 1–730 days, or an ISO date up to 730 days ahead. No invoice until it ends. |
cancelAtPeriodEnd | true ends the subscription when the current period finishes, with no new invoice. |
methods | Which hosted checkout methods its invoices offer: pago_movil, c2p, vpos, usdt, usdc, cop. Omit to offer every method your account accepts. |
metadata | Your own key/value pairs. |
| Status | Meaning |
|---|---|
trialing | In the free trial. |
active | Paid up, or its invoice was issued less than a day ago. |
past_due | An invoice is unpaid one day after it was issued. Collection attempts continue. |
canceled | Ended. cancellationReason is requested (by you or at period end) or payment_failed (no payment after the last attempt). |
| To… | Call |
|---|---|
| List | GET /v1/subscriptions?customer=cus_…&status=past_due&limit=25 — newest first; pass nextCursor as cursor for the next page. |
| Cancel at period end | POST /v1/subscriptions/:id with { "cancelAtPeriodEnd": true } (send false to undo). |
| Cancel now | DELETE /v1/subscriptions/:id. Open invoices are voided. |
| Switch collection method | POST /v1/subscriptions/:id with { "collectionMethod": "charge_automatically" } (or back to send_invoice, which needs a customer email). Applies from the next invoice; the same checks as on create apply, including the per-charge limit (Automatic charging). |
| Change the attempts or methods | POST /v1/subscriptions/:id with collectionAttempts or methods. Applies from the next invoice. |
Invoices and the hosted invoice page
Each period has one invoice, numbered per account with no gaps (K7Q2M9XA-0001, -0002, …). It is due when issued. Its hostedInvoiceUrl is a payment page in your name: the customer picks a method and pays the Bs amount at that moment’s rate. Emails show the amount in dollars only, because the bolívar amount depends on the day they pay.
| Status | Meaning | Can still be paid? |
|---|---|---|
open | Waiting for payment | Yes |
paid | Paid. paidAt, paymentId and billingFee are set | — |
uncollectible | Gave up collecting (after the last attempt, or by you) | Yes — a late payment still counts |
void | Canceled; the payment page closes | No |
| To… | Call |
|---|---|
| List | GET /v1/invoices?subscription=sub_… (also customer, status, cursor, limit). |
| Void | POST /v1/invoices/:id/void — only an open invoice. |
| Stop collecting | POST /v1/invoices/:id/mark-uncollectible — the page stays payable. |
| Record a payment made outside VEX Pay | POST /v1/invoices/:id/pay with { "paidOutOfBand": true } (cash, transfer). No money moves and no fee is charged. |
| Email it again | Portal → Billing → Invoices → Resend, at most once every 10 minutes. |
attemptsTotal,attemptCountandnextPaymentAttemptshow where the invoice is in its collection attempts.- When the invoice is paid through VEX Pay,
payment.completedincludesinvoice: { id, number, subscription }, so your existing payment handler knows which subscription it was for.
Collection, retries and cancellation
Every invoice gets N collection attempts, one a day: attempt 1 the moment it is issued, attempt 2 a day later, and so on. N is the subscription’s collectionAttempts (1–15), else your account default (3 unless you change it). N is fixed on the invoice when it is issued (attemptsTotal), so changing it later applies from the next invoice. Each attempt either emails the invoice (send_invoice) or debits the customer (charge_automatically; if no debit can be made, the attempt emails the invoice instead).
| When (from issue) | What happens | Webhooks |
|---|---|---|
| Day 0 | Attempt 1: invoice email “Factura … de <your business>”, or the first automatic debit | invoice.created, invoice.payment_attempt |
| Day 1 | Still unpaid → the invoice is overdue and the subscription becomes past_due. Attempt 2: reminder email or a new debit | invoice.overdue, customer.subscription.updated, invoice.payment_attempt |
| Days 2 … N−1 | One attempt a day. The last email says “Último aviso” | invoice.payment_attempt |
| Day N | Still unpaid → its open invoices become uncollectible, the subscription is canceled with cancellationReason: "payment_failed", and the customer receives the “Suscripción cancelada por falta de pago” email with a button to pay and reactivate | invoice.marked_uncollectible, customer.subscription.deleted |
With the default N = 3 that is: attempts on days 0, 1 and 2, past_due on day 1, canceled on day 3. With N = 1 the invoice becomes overdue and the subscription is canceled at the same time, one day after issue. Paying at any point stops the remaining attempts, emails a receipt and returns a past_due subscription to active. A past_due subscription keeps renewing: if a period ends while an invoice is unpaid, the next invoice is still issued.
Choosing N: 3 is a good default for monthly plans. Use more (up to 15) when your customers often pay late, and fewer for daily or weekly plans. Set the account default in Portal → Billing → Settings, or per subscription with collectionAttempts.
Emails to your customers
All emails are in Spanish, show your business name and logo from your business profile, come from facturas@mail.vexwallet.co as “<your business> vía VEX Pay”, and replies go to your business email. Each one is sent once — retries never send duplicates.
| When | |
|---|---|
| Factura … de <your business> | An invoice is issued (send_invoice), or an automatic debit could not be made |
| Recordatorio / Último aviso | Each later collection attempt of an unpaid invoice that is not a successful debit, with “intento X de N”; the last one is the final notice before cancellation |
| Pago recibido | An invoice is paid. For automatic debits it shows the Bs amount debited and the rate |
| Aviso de cobro | 3 days before each automatic renewal (when the payment method can still be debited), with a Bs estimate at today’s rate |
| Suscripción cancelada por falta de pago | After the last attempt, with a button to pay and reactivate |
Invoice and reminder emails show the dollar amount, the invoice number, the date the subscription will be canceled if unpaid, and the button to the hosted invoice page. They never show a fixed Bs amount, because it is calculated when the customer pays.
Trials and notices
- A trial starts with status
trialingand no invoice. When it ends, the first paid period starts and its invoice is issued. customer.subscription.trial_will_endfires 3 days before a trial ends (for trials longer than 3 days) — a good moment to remind the customer.invoice.upcomingfires 3 days before each renewal that will happen, with the amount and the next period. Automatic-debit customers also receive the “Aviso de cobro” email then.
Fees and your balance
An invoice paid through VEX Pay is a normal payment: its method’s fee applies as usual (see Pricing) and the money follows the usual balance hold (see Balances & holds). On top of that, VEX Pay charges a billing fee of 0.5% of each paid invoice (unless your agreement says otherwise), in the currency the customer paid. It appears on the invoice as billingFee and in your balance movements.
| Example: $25 plan paid by Pago Móvil at Bs 100/USD | Amount |
|---|---|
| Customer pays | Bs 2,500.00 |
| Pago Móvil fee | per your rates — see Pricing |
| Billing fee (0.5%) | Bs 12.50 |
| You receive | Bs 2,500.00 − Pago Móvil fee − Bs 12.50 |
An automatic debit (domiciliación) carries your bolívar pay-in rate, the same as Débito Inmediato (see Pricing), plus the billing fee. Invoices marked paid out of band (paidOutOfBand: true) move no money and carry no fee.
Webhooks
Subscribe your endpoint to the billing events you need (see Webhooks for registration and signature verification). Unless noted, data is the full object — the same JSON the API returns — plus livemode.
| Event | When | `data` |
|---|---|---|
customer.created / customer.updated | A customer was created or updated | Customer |
customer.deleted | A customer was deleted | Customer + deleted: true |
customer.subscription.created | A subscription was created | Subscription |
customer.subscription.updated | A new period started, it went past_due or back to active, or you changed cancel-at-period-end, the collection method or the payment method | Subscription |
customer.subscription.deleted | Canceled — by you, at period end, or for non-payment (cancellationReason) | Subscription |
customer.subscription.trial_will_end | 3 days before the trial ends | Subscription |
customer.subscription.reactivated | A late payment brought a subscription canceled for non-payment back | Subscription |
invoice.created | An invoice was issued | Invoice |
invoice.upcoming | 3 days before a renewal | subscription, customer, amountDueUsd, currency, renewsAt, periodStart, periodEnd |
invoice.payment_attempt | Each collection attempt | invoice, attempt, attemptsTotal, nextPaymentAttempt, collectionMethod, attemptedAt |
invoice.payment_failed | An automatic debit was rejected, or the invoice is above the per-charge limit (paymentId is then null) | invoice, attempt, attemptsTotal, nextPaymentAttempt, paymentId, failureCode, failureMessage |
invoice.overdue | Still unpaid one day after issue | Invoice |
invoice.paid | Paid — through VEX Pay or marked paid out of band | Invoice |
invoice.voided | Voided by you or by an immediate cancel | Invoice |
invoice.marked_uncollectible | Marked uncollectible by you or after the last attempt | Invoice |
payment_method.attached | A customer authorized domiciliación | Payment method (pending) |
payment_method.updated | It became active (first debit accepted) or inactive (a debit showed the authorization was canceled or suspended at the bank) | Payment method |
payment_method.detached | You detached it | Payment method |
curl --request POST "${API_URL}/v1/webhooks" \
--header "content-type: application/json" \
--header "x-api-key: ${VEXPAY_API_KEY}" \
--data '{
"url": "https://example.com/webhooks/vexpay",
"events": [
"customer.subscription.created", "customer.subscription.updated",
"customer.subscription.deleted", "customer.subscription.reactivated",
"invoice.paid", "invoice.payment_failed", "invoice.overdue",
"payment_method.attached", "payment_method.updated"
]
}'{
"event": "invoice.paid",
"data": {
"id": "in_3fd84b2d9ad14f74a2d90f2f56984db5",
"object": "invoice",
"number": "K7Q2M9XA-0001",
"status": "paid",
"customer": "cus_…",
"subscription": "sub_…",
"collectionMethod": "send_invoice",
"amountDueUsd": "25.00",
"amountPaidUsd": "25.00",
"currency": "USD",
"periodStart": "2026-10-21T14:00:00.000Z",
"periodEnd": "2026-11-21T14:00:00.000Z",
"dueDate": "2026-10-21T14:00:00.000Z",
"attemptsTotal": 3,
"attemptCount": 1,
"nextPaymentAttempt": null,
"paidAt": "2026-10-21T15:12:40.000Z",
"paidOutOfBand": false,
"paymentId": "9b2c…",
"billingFee": { "amount": "12.50", "currency": "VES", "percent": "0.5" },
"hostedInvoiceUrl": "https://pay.vexwallet.co/pay/in-…",
"createdAt": "2026-10-21T14:00:00.000Z",
"livemode": true
},
"timestamp": "2026-10-21T15:12:41.000Z"
}Test mode
- Everything above works with your Test key, with the same webhooks. Test and live objects are separate.
- Emails go only to your business-profile email. A test-mode email is delivered only when the customer’s email is the email on your business profile; every other address is suppressed. Use that address on your test customers to see exactly what your customers receive.
- Pay test invoices on their hosted page with the test payment methods.
- Automatic charging works in test mode without asking VEX Pay to enable it: a simulator stands in for the bank. See Automatic charging for the phone numbers that simulate each outcome.
- Test clocks are coming soon. Until then, try renewals and dunning with a daily price (
"recurring": { "interval": "day" }): a new invoice every 24 hours, and an unpaid one reachespast_dueafter a day and cancellation after N days.
Errors
Errors use the usual shape: { "error": "<code>", "message": "…" }, plus field for invalid input.
| Status | error | Meaning |
|---|---|---|
| 400 | invalid_data | A field is missing or invalid; see field |
| 400 | customer_email_required | send_invoice needs a customer with an email |
| 400 | price_inactive | The price is archived |
| 400 | plan_changes_not_supported | A subscription’s price cannot be changed |
| 400 | subscription_canceled | The subscription is already canceled |
| 400 | invoice_not_open | Only an open invoice can be voided, marked uncollectible or marked paid |
| 400 | method_not_allowed | Domiciliación is not enabled for your account |
| 400 | automatic_charging_unavailable | Automatic charging (and setup sessions) are not enabled yet for live accounts. Never returned in test mode |
| 400 | amount_exceeds_limit | charge_automatically for a price above Bs 1,500,000 at today’s rate; the message gives the approximate USD maximum. Use send_invoice |
| 400 | payment_method_required | The customer has not authorized domiciliación yet |
| 400 | payment_method_detached | The payment method was detached |
| 404 | not_found | No such object in this account (or in this mode) |
| 409 | customer_has_active_subscriptions | Cancel the customer’s subscriptions before deleting it |
| 422 | amount_below_minimum | The price is below your minimum charge |
Full request and response schemas for every billing endpoint are in the API reference (API reference).