How charges fire
The scheduler's sweeper picks due subscriptions, locks them, and a worker calls batchCharge on chain for each one. Safe with several replicas, idempotent at the contract, and it reclaims stale locks.
The scheduler has a cron called the subscription sweeper. It runs every 15 minutes by default (SUBSCRIPTION_SWEEPER_CRON). Each run it finds subscriptions that are due, locks them, and enqueues one charge job per subscription.
The sweep query
The sweeper locks its rows in a single statement:
What each part does:
FOR UPDATE SKIP LOCKEDlets several scheduler replicas run side by side. A row another replica is working on is skipped, so two replicas never pick up the same subscription in the same run.- The
chargeLockclause is the stale-lock reclaim from Safety nets.$3is 600 seconds and is fixed in code. A lock older than ten minutes is treated as abandoned. nextRetryAtholds back a subscription that failed recently until its next retry time. See Recovery.trialingis included so the first charge after a trial is picked up.onchainSubscriptionId IS NOT NULLskips rows the indexer has not linked to the contract yet.LIMIT $2caps each run. The default is 100, set withSUBSCRIPTION_SWEEP_LIMIT.
One job per subscription
Each locked row becomes one job on the strimz.subscription.due queue, and each job charges one subscription. A bad subscription cannot hold up the others.
The worker:
- Releases the lock and stops if the subscription is
cancelledorlapsed, or has no on-chain id. - Asks the contract whether the charge is due. If not, it releases the lock and stops.
- Picks the first unused charge attempt id for this period (see below). If all 8 are used, it releases the lock and stops.
- Calls
batchCharge([subscriptionId], [chargeAttemptId]). - Releases the lock, whether the broadcast succeeded or failed.
The worker only broadcasts the transaction. The result is projected by the indexer.
The on-chain call
Only the CHARGER_ROLE can call it. The scheduler holds that role.
The charge attempt id is:
attempt runs from 0 to 7. The id is deterministic, so replaying the same attempt gives the same id. The contract records used ids. A second call with a used id does not revert: the contract emits SubscriptionChargeSkipped with outcome Duplicate and moves on. The contract marks an id used only when it attempts the transfer, so on Charged or TransferFailed. Skips before that point, such as InsufficientFunds, leave the id unused, and the next retry sends the same id again. After a TransferFailed, the next retry uses the next attempt number.
No failure reverts the call. Each subscription gets an outcome:
| Outcome | Meaning |
|---|---|
Charged | The payer was charged |
Duplicate | This attempt id was already used |
Unknown | No subscription with this id |
Cancelled | The subscription is cancelled |
NotDue | nextChargeAt is still in the future |
Ended | The subscription is past its end time |
RevokedApproval | The allowance is too low |
InsufficientFunds | The balance is too low |
MerchantInactive | The merchant is not active in the registry |
TransferFailed | The token transfer failed |
If a subscription is more than one period behind, the contract charges one period and moves nextChargeAt past the missed ones. It does not charge for each missed period.
What the indexer projects
The indexer writes the SubscriptionCharge rows. There is no separate API route for listing charges; they arrive in the subscription.charged and subscription.charge_failed webhook payloads.
On SubscriptionCharged, the indexer writes a charge row with status: 'succeeded' and a transaction row, moves the period forward, resets the retry counter, sets trialing or at_risk subscriptions to active, and fires subscription.charged.
On SubscriptionChargeSkipped, the indexer writes a charge row with status: 'failed' and the outcome. Only payment failures (insufficient_funds, revoked_approval, merchant_inactive, transfer_failed) fire subscription.charge_failed and move an active or at_risk subscription to at_risk. The race-condition guard keeps a subscription that already paid this period from being downgraded. Other outcomes such as not_due and duplicate are recorded and nothing else happens. In the API, skipped is the fallback for an outcome the contract adds later.
When charges don't fire
nextChargeAtis in the future. This is the normal case.nextRetryAtis in the future after a recent failure.- The subscription is
cancelledorlapsed. - The indexer has not linked the row to its on-chain id yet.
Archiving a plan does not stop charges for existing subscribers.
The scheduler does not check its gas balance before charging. A separate cron checks the relayer and scheduler balances every 15 minutes and emails an operator when one drops below GAS_BALANCE_THRESHOLD_USDC. See Self-hosting the scheduler.
There is no call to trigger a charge on demand. The next sweep picks up anything due.

