Recurring Billing
How the recurring cron job charges subscriptions on schedule.
Overview
JoPay supports recurring payments through a cron-triggered background job. Merchants create recurring plans with a fixed fiat amount and frequency (weekly, biweekly, or monthly). The cron job runs hourly, picks up plans that are due, recalculates the USDC amount using the current FX rate, and creates a payment request for each charge.
Cron Behavior
The /api/cron/process-recurring endpoint is called hourly by a Vercel cron trigger. Each invocation:
- Fetches due plans — queries all
RecurringPlanswith statusactiveandnext_charge_at <= now, ordered by oldest first. - Batches of 100 — plans are fetched in pages of 100. If there are more than 100 due plans, the job continues fetching batches until all are handled or the time budget runs out.
- 50-second time budget — the job enforces a 50s wall-clock limit (Vercel Pro timeout is 60s). If time runs out mid-batch, remaining plans are deferred to the next hourly run.
FX Recalculated at Charge Time
Each recurring plan stores the fiat amount (e.g. 5000 COP), but the USDC equivalent is not locked at plan creation. Instead, the cron job recalculates USDC at charge time using the partner's current FX rate:
fiatAmount = plan.fiat_amount_int / 100
usdcAmount = fiatAmount / partner.fx_rate_fiat_per_usdc
usdcMicro = floor(usdcAmount * 1_000_000)This means the USDC amount varies slightly from charge to charge as exchange rates move.
Failure Handling
When a charge fails (FX rate stale, merchant disabled, wallet type mismatch, or any unexpected error), the system:
- Increments the plan's
consecutive_failurescounter. - Advances
next_charge_atto the next scheduled time so the plan is retried on the next cycle. - If
consecutive_failuresreaches 3, the plan status is set topaused. Paused plans are not picked up by the cron job until manually reactivated.
When a charge succeeds, consecutive_failures resets to 0.
Charge Lifecycle
Plan is due
The cron job finds the plan because next_charge_at <= now and status is active.
FX rate is validated
The partner's current FX rate is fetched. If it is older than 24 hours, the charge is skipped and marked as a failure.
Payment request is created
A new Requests record is created with payment_mode: "recurring", the recalculated USDC amount, and a unique idempotency key recurring:planId:chargeNumber.
Recurring charge record is created
A RecurringCharges row links the plan to the request. Its initial status is pending.
Plan is advanced
The plan's charges_completed is incremented, next_charge_at is advanced to the next scheduled date, and consecutive_failures resets to 0.
Webhook is dispatched
A payment.proof_attached webhook event is sent to the partner's configured webhook URL with the new request details.
Webhook Events per Charge
Each recurring charge triggers the same webhook events as a regular payment:
payment.proof_attached— sent when the charge creates the payment request.payment.proof_verified— sent later if/when the on-chain proof is verified.
Your webhook handler can distinguish recurring charges by checking the request_id against the RecurringCharges table, or by looking for the Recurring: prefix in the request memo.
Supported Frequencies
| Frequency | Interval |
|---|---|
weekly | Every 7 days |
biweekly | Every 14 days |
monthly | Every calendar month |
Max Charges
Plans can optionally set a max_charges limit. When charges_completed reaches this value, the plan is automatically cancelled and no further charges are attempted.