Scheduled payments
Set up and run scheduled payments in your collection module.
Scheduled payments let your collection module plan a policy's future payments and collect them automatically. You tell the platform when a payment is due and how much to collect. The platform creates the payment on the due date. It then either submits the payment through the platform batcher, or hands it to your module's own submission code.
This guide covers:
- How scheduled payments work, end to end.
- The prerequisites your collection module needs.
- Turning the feature on in
root.config.json. - Creating a schedule from a lifecycle hook.
- Making payments recur.
- Changing or cancelling a schedule.
- Submitting payments to your provider.
- The lifecycle events you can listen to.
- Common troubleshooting checks.
Prerequisites
Before you create scheduled payments:
- Create a collection module.
- Add a payment method to the policy, or make a specific payment method available to pass as
payment_method_id. - Decide whether your provider is API-based or file-based.
- Confirm which lifecycle hooks your module needs to implement, such as
afterPolicyIssued,afterPaymentSucceeded, andsubmitPayments.
How it works
A scheduled payment moves through three stages:
- Schedule. A lifecycle hook in your module returns a
schedule_paymentaction. The platform records the schedule. Nothing is collected yet; this is a forecast of a future payment. - Create. On the due date, or within the
submissionLeadTimewindow, the platform turns the schedule into a real payment withpendingstatus. - Submit. The platform sends the pending payment to a provider. For API providers, your module submits it through a hook you write.
You never call an API to create a schedule and you never write to billing tables. You return actions and write hooks; the platform does the rest.
Turn it on
Add a billingSettings block to your root.config.json. Submission settings live under a batching object; retry settings live under retry.
{
"billingSettings": {
"batching": {
"enabled": true,
"submitPaymentsFunction": "submitPayments",
"submitBatchSize": 100,
"scheduleTimeUtc": "05:00",
"submissionLeadTime": 2,
"latestSubmissionTimeUtc": "20:00"
},
"retry": { "maxAttempts": 3, "backoffDays": 1, "backoffMultiplier": 2 }
}
}batching:
| Field | Type | Description |
|---|---|---|
enabled | boolean | Set to true to have your module submit payments through its own hook (an API payment provider). Leave it false to use a file-based debit provider through the platform batcher. Defaults to false. |
submitPaymentsFunction | string | The name of your submission hook. Required when enabled is true; ignored otherwise. See Submitting payments. |
submitBatchSize | number | The largest number of payments passed to your submission hook in one call, between 1 and 500. Defaults to 100. The platform splits a larger batch into chunks of this size. |
scheduleTimeUtc | string | The time of day, in UTC HH:MM, that the platform submits your due payments. Defaults to "05:00". |
submissionLeadTime | number | How many days before the due date the platform may create the payment. Defaults to 0 (create on the due date). |
latestSubmissionTimeUtc | string | The latest time of day, in UTC HH:MM, the platform will still try to submit today. At or after this time, submission moves to the next day. Defaults to "00:00", which is treated as end of day (midnight), so the default never defers during the day. Set it comfortably before your provider's cutoff so there is room to retry. |
retry (optional):
| Field | Type | Description |
|---|---|---|
maxAttempts | number | How many times a failed payment is retried. Defaults to 0. |
backoffDays | number | Days to wait before the first retry. Defaults to 0. |
backoffMultiplier | number | Multiplier applied to the wait after each attempt. Defaults to 1. |
Dates and amounts
Use calendar dates without times for scheduled_for, billing_period_start, and billing_period_end. The examples use ISO calendar date strings such as "2026-08-01". Keep submission times in UTC HH:MM.
Set expected_amount in the smallest currency unit, for example cents.
Create a schedule
A collection module creates a scheduled payment by returning a schedule_payment action from a lifecycle hook.
The examples use addMonths as a date helper. Replace it with your module's date utility if you use a different helper.
export const afterPolicyIssued = async ({ policy }) => {
const billingPeriodEnd = addMonths(policy.first_debit_date, 1);
return [
{
name: 'schedule_payment',
scheduled_for: policy.first_debit_date,
expected_amount: policy.premium_amount + (policy.outstanding_balance ?? 0),
currency: policy.currency,
premium_type: 'recurring',
billing_period_start: policy.first_debit_date,
billing_period_end: billingPeriodEnd,
// payment_method_id omitted, so the platform uses the policy's payment method.
},
];
};The hook returns an array of actions. Return [] to schedule nothing. Return more than one action to schedule several payments at once.
Action fields
| Field | Required | Description |
|---|---|---|
name | Yes | Always "schedule_payment". |
scheduled_for | Yes | The date the payment is due. A calendar date, not a time, for example "2026-08-01". |
expected_amount | Yes | The amount to collect, in the smallest currency unit. |
currency | Yes | The ISO currency code, for example "ZAR". |
premium_type | Yes | The kind of payment, for example "recurring". One of the platform's premium types. |
billing_period_start | Yes | The first day of the period this payment covers. |
billing_period_end | Yes | The last day of the period this payment covers. |
payment_method_id | No | A specific payment method. Leave it out to use the policy's payment method. |
The platform validates each action, records the schedule, and emits a collection_scheduled event.
Make payments recur
A schedule_payment action schedules one payment. To collect every period, return a new action from the lifecycle hook that runs after each payment. Recurrence comes from your hooks, not from configuration.
afterPolicyIssuedschedules the first payment when the policy goes live.afterPaymentSucceededschedules the next payment after each payment succeeds.
export const afterPaymentSucceeded = async ({ policy, payment }) => {
const next = addMonths(payment.billing_period_start, 1);
const nextPeriodEnd = addMonths(next, 1);
return [
{
name: 'schedule_payment',
scheduled_for: next,
expected_amount: policy.premium_amount,
currency: policy.currency,
premium_type: 'recurring',
billing_period_start: next,
billing_period_end: nextPeriodEnd,
},
];
};afterPaymentSucceeded is what keeps the chain going. Each successful payment runs the hook again, and the hook returns the next schedule_payment. Your hook decides the next due date and amount, so you control the cadence. If your module does not implement afterPaymentSucceeded, the policy is scheduled once and never again.
Change or cancel a schedule
To move a scheduled payment to a different date, return a reschedule_payment action. To cancel one before it is created, return an unschedule_payment action. Both take the id of the schedule you are changing.
// Move a scheduled payment to a new date.
return [
{
name: 'reschedule_payment',
scheduled_payment_id: scheduledPayment.id,
new_scheduled_for: '2026-08-01',
reason: 'policyholder requested a later date', // optional
},
];
// Cancel a scheduled payment.
return [
{
name: 'unschedule_payment',
scheduled_payment_id: scheduledPayment.id,
reason: 'policy_cancelled',
},
];reschedule_payment fields:
| Field | Required | Description |
|---|---|---|
name | Yes | Always "reschedule_payment". |
scheduled_payment_id | Yes | The id of the schedule to move. |
new_scheduled_for | Yes | The new due date. |
reason | No | A short note on why it moved. |
unschedule_payment fields:
| Field | Required | Description |
|---|---|---|
name | Yes | Always "unschedule_payment". |
scheduled_payment_id | Yes | The id of the schedule to cancel. |
reason | Yes | One of "manual_admin", "payment_method_revoked", or "policy_cancelled". |
A reschedule emits collection_rescheduled; an unschedule emits collection_unscheduled.
Submit payments
How a due payment reaches a provider depends on the provider type.
| API payment provider | File-based debit provider | |
|---|---|---|
| Config | batching.enabled: true + submitPaymentsFunction | batching.enabled: false |
| Who submits | Your module's hook | The platform batcher |
| Batching | You decide, inside your hook | The platform handles it |
| Code you write | A submitPayments hook | None |
The submitPayments hook
submitPayments hookFor API providers, the platform calls your submission hook with the whole batch of your module's due payments in one call. You submit them to your provider and return a result for each one.
export const submitPayments = async ({ payments, organization, environment }) => {
const results = [];
for (const p of payments) {
try {
// `provider` is your payment provider's client.
const charge = await provider.charge({
amount: p.amount,
currency: p.currency,
idempotencyKey: p.payment_id,
});
results.push({
payment_id: p.payment_id,
status: 'submitted',
provider_reference: charge.id,
});
} catch (error) {
results.push({
payment_id: p.payment_id,
status: 'failed',
failure_reason: error instanceof Error ? error.message : 'provider_submission_failed',
});
}
}
return { results };
};Parameters
The hook receives { payments, organization, environment }. Each entry in payments has these fields:
| Field | Description |
|---|---|
payment_id | The payment's id. Use it as the provider idempotency key. |
policy_id | The policy the payment belongs to. |
amount | The amount to collect, in the smallest currency unit. |
currency | The ISO currency code. |
premium_type | The kind of payment, for example "recurring". One of the platform's premium types. |
billing_period_start | The first day of the period this payment covers. |
billing_period_end | The last day of the period this payment covers. |
policyholder | The policyholder object. |
policy | The policy object. |
organization is the organization the payments belong to. environment is sandbox or production.
Return shape
Return { results: [...] }, with one entry per payment. Report the result of handing the payment to your provider, not the final settlement: use submitted when the provider has accepted it, and failed when the submission itself failed. The platform resolves a submitted payment to successful or failed later, when your provider confirms it.
| Field | Required | Description |
|---|---|---|
payment_id | Yes | The payment_id from the matching input payment. |
status | Yes | Either "submitted" or "failed". |
provider_reference | When submitted | The provider's id for the payment, for example a charge or transaction id. Used to match the later confirmation. |
failure_reason | When failed | A short reason the submission failed. |
The platform validates your return value before it applies any results.
A mixed batch can return both submitted and failed payments:
{
"results": [
{
"payment_id": "pay_123",
"status": "submitted",
"provider_reference": "ch_456"
},
{
"payment_id": "pay_789",
"status": "failed",
"failure_reason": "card_declined"
}
]
}Idempotency
Use payment_id as the idempotency key with your provider, as shown above. The platform may call your hook again if a batch is redelivered. Keying on payment_id means a retried call will not charge a policyholder twice.
Lifecycle events
Each stage emits an event you can act on through your module's lifecycle hooks. Event names use snake_case.
| Event | Emitted when |
|---|---|
collection_scheduled | A schedule is created from your schedule_payment action. |
collection_attempted | A pending payment is created on the due date. |
collection_submitted | A payment is sent to the provider. |
collection_successful | The provider confirms the payment succeeded. |
collection_failed | The payment failed. |
collection_reversed | A successful payment was later reversed. |
collection_rescheduled | A scheduled payment's date was changed. |
collection_unscheduled | A scheduled payment was cancelled before it was created. |
Troubleshooting
A scheduled payment was not created
Check that your lifecycle hook returned a schedule_payment action with all required fields. Confirm that scheduled_for, expected_amount, currency, premium_type, billing_period_start, and billing_period_end are present.
A payment was created but not submitted
Check billingSettings.batching.enabled, submitPaymentsFunction, and scheduleTimeUtc. For API payment providers, confirm that the function named in submitPaymentsFunction exists and returns one result for each input payment.
A payment was submitted after your provider cutoff
Set latestSubmissionTimeUtc comfortably before your provider's cutoff. At or after that time, the platform moves submission to the next day.
A policyholder was charged twice
Confirm that your provider submission uses payment_id as the idempotency key. The platform may redeliver a batch, and the provider should treat the same payment_id as the same charge request.
Worked example
A simple monthly card module using an addMonths date helper:
{
"billingSettings": {
"batching": {
"enabled": true,
"submitPaymentsFunction": "submitPayments",
"submitBatchSize": 100,
"scheduleTimeUtc": "05:00",
"submissionLeadTime": 2,
"latestSubmissionTimeUtc": "20:00"
},
"retry": { "maxAttempts": 3, "backoffDays": 1, "backoffMultiplier": 2 }
}
}export const afterPolicyIssued = async ({ policy }) => {
const billingPeriodEnd = addMonths(policy.first_debit_date, 1);
return [
{
name: 'schedule_payment',
scheduled_for: policy.first_debit_date,
expected_amount: policy.premium_amount,
currency: policy.currency,
premium_type: 'recurring',
billing_period_start: policy.first_debit_date,
billing_period_end: billingPeriodEnd,
},
];
};
export const afterPaymentSucceeded = async ({ policy, payment }) => {
const next = addMonths(payment.billing_period_start, 1);
const nextPeriodEnd = addMonths(next, 1);
return [
{
name: 'schedule_payment',
scheduled_for: next,
expected_amount: policy.premium_amount,
currency: policy.currency,
premium_type: 'recurring',
billing_period_start: next,
billing_period_end: nextPeriodEnd,
},
];
};
export const submitPayments = async ({ payments }) => {
const results = await Promise.all(
payments.map(async (p) => {
try {
const charge = await provider.charge({
amount: p.amount,
currency: p.currency,
idempotencyKey: p.payment_id,
});
return {
payment_id: p.payment_id,
status: 'submitted',
provider_reference: charge.id,
};
} catch (error) {
return {
payment_id: p.payment_id,
status: 'failed',
failure_reason: error instanceof Error ? error.message : 'provider_submission_failed',
};
}
}),
);
return { results };
};This module schedules the first payment at issue, charges it through its provider on the due date, and schedules the next month after each success.