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 RecurringPlans with status active and next_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.
The cron endpoint requires a shared secret for authorization. It rejects unauthenticated requests with a 401 status.

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.

If the partner's FX rate is older than 24 hours, the charge is rejected with an error. This prevents using stale rates for real charges. Make sure your FX rate is refreshed regularly (see FX Rates).

Failure Handling

When a charge fails (FX rate stale, merchant disabled, wallet type mismatch, or any unexpected error), the system:

  1. Increments the plan's consecutive_failures counter.
  2. Advances next_charge_at to the next scheduled time so the plan is retried on the next cycle.
  3. If consecutive_failures reaches 3, the plan status is set to paused. 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

FrequencyInterval
weeklyEvery 7 days
biweeklyEvery 14 days
monthlyEvery 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.