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:

  1. How scheduled payments work, end to end.
  2. The prerequisites your collection module needs.
  3. Turning the feature on in root.config.json.
  4. Creating a schedule from a lifecycle hook.
  5. Making payments recur.
  6. Changing or cancelling a schedule.
  7. Submitting payments to your provider.
  8. The lifecycle events you can listen to.
  9. Common troubleshooting checks.

Prerequisites

Before you create scheduled payments:

  1. Create a collection module.
  2. Add a payment method to the policy, or make a specific payment method available to pass as payment_method_id.
  3. Decide whether your provider is API-based or file-based.
  4. Confirm which lifecycle hooks your module needs to implement, such as afterPolicyIssued, afterPaymentSucceeded, and submitPayments.

How it works

A scheduled payment moves through three stages:

  1. Schedule. A lifecycle hook in your module returns a schedule_payment action. The platform records the schedule. Nothing is collected yet; this is a forecast of a future payment.
  2. Create. On the due date, or within the submissionLeadTime window, the platform turns the schedule into a real payment with pending status.
  3. 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:

FieldTypeDescription
enabledbooleanSet 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.
submitPaymentsFunctionstringThe name of your submission hook. Required when enabled is true; ignored otherwise. See Submitting payments.
submitBatchSizenumberThe 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.
scheduleTimeUtcstringThe time of day, in UTC HH:MM, that the platform submits your due payments. Defaults to "05:00".
submissionLeadTimenumberHow many days before the due date the platform may create the payment. Defaults to 0 (create on the due date).
latestSubmissionTimeUtcstringThe 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):

FieldTypeDescription
maxAttemptsnumberHow many times a failed payment is retried. Defaults to 0.
backoffDaysnumberDays to wait before the first retry. Defaults to 0.
backoffMultipliernumberMultiplier 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

FieldRequiredDescription
nameYesAlways "schedule_payment".
scheduled_forYesThe date the payment is due. A calendar date, not a time, for example "2026-08-01".
expected_amountYesThe amount to collect, in the smallest currency unit.
currencyYesThe ISO currency code, for example "ZAR".
premium_typeYesThe kind of payment, for example "recurring". One of the platform's premium types.
billing_period_startYesThe first day of the period this payment covers.
billing_period_endYesThe last day of the period this payment covers.
payment_method_idNoA 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.

  • afterPolicyIssued schedules the first payment when the policy goes live.
  • afterPaymentSucceeded schedules 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:

FieldRequiredDescription
nameYesAlways "reschedule_payment".
scheduled_payment_idYesThe id of the schedule to move.
new_scheduled_forYesThe new due date.
reasonNoA short note on why it moved.

unschedule_payment fields:

FieldRequiredDescription
nameYesAlways "unschedule_payment".
scheduled_payment_idYesThe id of the schedule to cancel.
reasonYesOne 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 providerFile-based debit provider
Configbatching.enabled: true + submitPaymentsFunctionbatching.enabled: false
Who submitsYour module's hookThe platform batcher
BatchingYou decide, inside your hookThe platform handles it
Code you writeA submitPayments hookNone

The submitPayments hook

For 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:

FieldDescription
payment_idThe payment's id. Use it as the provider idempotency key.
policy_idThe policy the payment belongs to.
amountThe amount to collect, in the smallest currency unit.
currencyThe ISO currency code.
premium_typeThe kind of payment, for example "recurring". One of the platform's premium types.
billing_period_startThe first day of the period this payment covers.
billing_period_endThe last day of the period this payment covers.
policyholderThe policyholder object.
policyThe 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.

FieldRequiredDescription
payment_idYesThe payment_id from the matching input payment.
statusYesEither "submitted" or "failed".
provider_referenceWhen submittedThe provider's id for the payment, for example a charge or transaction id. Used to match the later confirmation.
failure_reasonWhen failedA 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.

EventEmitted when
collection_scheduledA schedule is created from your schedule_payment action.
collection_attemptedA pending payment is created on the due date.
collection_submittedA payment is sent to the provider.
collection_successfulThe provider confirms the payment succeeded.
collection_failedThe payment failed.
collection_reversedA successful payment was later reversed.
collection_rescheduledA scheduled payment's date was changed.
collection_unscheduledA 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.