2026-09-20

How Crypto Split Payment Distributions Actually Settle

A charge with recipients defined is not the same thing as recipients getting paid. charge.confirmed tells you a customer's funds landed at the charge's split address. It does not tell you the seller's wallet, the platform fee wallet, or the referral wallet have received anything yet. That is a separate step — distribution — and it has its own mechanics, its own failure states, and its own webhook.

Most integrations never need to think about this. The default worker handles it end to end. But once you are operating split payments in production — reconciling payouts, alerting on stuck money, or just explaining to a merchant why their wallet balance hasn't updated yet — you need to know what actually happens in that gap.


Distribution is a separate event, not a side effect

A charge that settles across a (token, network) pair creates its own distribution record the moment payment is confirmed. That record moves through a status of its own — pending, processing, completed, or failed — independent of the charge's own status. A charge can sit at confirmed for several minutes while its distribution is still pending, and that is completely normal.

charge.settled is the event that means money actually reached the recipients. If your fulfillment logic only listens for charge.confirmed, you know a payment happened. You do not yet know the split paid out. For a single-recipient charge that distinction rarely matters in practice. For a marketplace paying a seller and taking a platform fee, it is the difference between "the customer paid" and "the seller has been paid" — two different facts your support team will get asked about separately.


Distribution is permissionless, on purpose

The mechanism underneath a split is 0xSplits, and its distribute() function is public. Anyone can call it — not just Klappay's own infrastructure. Klappay runs a worker that claims and executes distributions automatically, but it holds no exclusive right to do so.

Every distribution carries a distributorFeePercent, frozen at charge creation — a small cut of the split balance paid to whoever successfully calls distribute() first. Klappay's worker claims a distribution after a five-minute grace period from the moment it became payable. Before that window closes, anyone — including a merchant running their own automation — can call distribute() first and collect that fee instead.

This is not an edge case to work around. It is what makes the split resilient to Klappay's own infrastructure going down: the underlying contract does not depend on Klappay's worker being alive, only on someone, eventually, calling a public function.


The state machine behind a payout

A distribution's lifecycle is intentionally narrow:

  • pending — queued, not yet claimed by a distributor.
  • processing — a distributor has claimed it and is submitting the on-chain transaction.
  • completed — the recipient split has the funds.
  • failed — every automatic retry has been exhausted.

Failures retry on a fixed backoff — immediately, then after 5 minutes, 30 minutes, 2 hours, and 1 day. If the fifth attempt still fails, the distribution moves to failed and Klappay fires charge.settlement_failed. That webhook is the one to alert on: it means a payment was detected and confirmed, but the money has not reached its recipients, and automatic retries are done.

Two safety nets sit underneath that state machine, both worth knowing about if you're debugging a stuck payout:

  • If a worker process dies mid-transaction, a distribution left in processing for more than 20 minutes is automatically reset back to pending so another attempt can claim it. You will not see a payout silently vanish because a container restarted.
  • If a split's on-chain balance reads zero well past the point it should have been distributed, with no matching outgoing transfer found, that is logged as a suspicious state rather than assumed to be fine — a split that's empty for a reason other than "we already paid it" is treated as something to investigate, not something to ignore.

Watching distributions from your own side

The Node SDK exposes the same state the worker itself operates on, so you can reconcile independently of webhook delivery:

for await (const event of klap.distributions.streamPending()) {
if (event.type === 'distribution.available') {
console.log(
event.distribution.splitAddress,
event.distribution.estimatedRewardAmount,
event.distribution.graceEndsAt,
)
}
if (event.type === 'distribution.claimed') {
console.log('claimed', event.splitAddress)
}
}

streamPending() is a live SSE feed of distribution.available and distribution.claimed events, scoped to your own environment. It is not a bootstrap — connect it first, then call list() or listAll() to snapshot the current pending set, and apply every event you received in between as an idempotent add or remove on top of that snapshot:

for await (const distribution of klap.distributions.listAll()) {
console.log(distribution.splitAddress, distribution.recipients)
}

Each PendingDistribution carries the split address, the exact recipient list and percentages, the frozen distributorFeePercent, an estimatedRewardAmount, and graceEndsAt — enough to decide, in real time, whether a payout is still waiting on Klappay's worker or already claimed.


When this matters enough to act on

For most integrations, nothing here changes your code. The default worker settles distributions within its grace period, charge.settled fires, and that is the whole story.

It starts to matter once a single split represents enough value that latency or reliability around the payout itself becomes a product concern — a high-volume marketplace, a payroll-style disbursement, or a platform whose sellers watch their wallet balance closely. In that world, three things are worth building around:

  1. Alert on charge.settlement_failed, not just its absence. A charge stuck at confirmed with no charge.settled after a reasonable window is money detected but not delivered — a support and reconciliation problem, not a customer-facing one.
  2. Use distributions.streamPending()/list() to reconcile payout state directly against your own records, independent of whether a webhook delivery succeeded.
  3. Decide deliberately whether to ever claim a distribution yourself. Running your own distributor after the grace period expires is possible and collects the distributorFeePercent reward, but it's operational surface area most teams don't need — know it's an option before you build it, not after a support ticket forces the question.

A split recipient list is a promise about where money should go. Distribution is the separate, observable, sometimes-public process that keeps that promise. Treat it as its own event in your system, and "the seller hasn't been paid yet" stops being a mystery and starts being a state you can query.