Recipients
Exported from recipients.ts. A Recipient is a trusted, pre-registered payout destination — the thing a charge's splitRecipients entries reference by recipientId instead of a raw address (see charges.md).
Why registration instead of a raw address
An earlier version of splitRecipients accepted a raw address directly in the charge-creation request — any charges:write key could redirect a slice of a payout to an address nobody had verified. Recipients close that: an address has to be registered once, ahead of time, before it can ever receive money through a split.
Registering a recipient
ts
import { CreateRecipientSchema } from '@klappay/types'
CreateRecipientSchema.parse({
address: '0x1111111111111111111111111111111111111111',
label: 'supplier', // optional, your own bookkeeping — never interpreted
})POST /v1/recipients — requires the recipients:write scope. Idempotent: registering an address already known to your organization/environment updates its label (and revives it if it was previously revoked) instead of creating a duplicate row.
The response is a Recipient:
ts
import { RecipientSchema } from '@klappay/types'
RecipientSchema.parse({
id: 'rc_abc123', // this, not the address, is what splitRecipients[].recipientId references
environment: 'live',
address: '0x1111111111111111111111111111111111111111',
label: 'supplier',
payout: false,
createdAt: '2026-01-01T00:00:00.000Z',
})Using one in a split
Once registered, reference it by id — never send the address itself:
ts
{
amount: 100,
acceptedPayments: [{ token: 'USDC', network: 'base' }],
splitRecipients: [{ recipientId: 'rc_abc123', percent: 10 }],
}GET /v1/recipients (recipients:read scope) lists your organization's non-revoked recipients for the calling key's environment, newest first, with the same cursor-based ?limit/?cursor pagination as GET /v1/charges (data/nextCursor/hasMore).
DELETE /v1/recipients/{id} (recipients:write scope) revokes one — it can no longer be referenced in a new split, and (if it currently has payout: true) revoking it also invalidates any API key whose own payoutAddress matches it, on that key's very next request. See the payout section below for why revoking a payout: true recipient needs a stricter scope than registering one does. Charges that already referenced a since-revoked recipient are unaffected — the resolved address is frozen into the charge at creation time, not a live reference to the Recipient row.
payout — a separate, stricter claim
recipients:write alone never makes a recipient eligible to become an API key's own payoutAddress — only being referenceable in a split. Elevating a recipient to payout: true requires the separate recipients:manage_payout scope:
ts
import { SetRecipientPayoutSchema } from '@klappay/types'
SetRecipientPayoutSchema.parse({ payout: true })PATCH /v1/recipients/{id} — meant to be held only by a key that already went through its own out-of-band approval for this specific action (e.g. Dashboard's own internal key, after session + owner role + email confirmation on its side), never a merchant-facing or third-party integration key. See api-keys.md's scope table for the full rationale, including why charges:split_write can never be combined with recipients:write/recipients:manage_payout on the same key.
Two consequences of the split between "referenceable in a split" and "payout-eligible":
- Registering (or re-registering) a recipient always resets
payoutback tofalse—recipients:writecan never grant or preserve a prior payout approval by itself. - Revoking a recipient that currently has
payout: truerequiresrecipients:manage_payout, not justrecipients:write— otherwise a lower-trust key could disable any other API key in the organization by revoking a recipient it didn't have the authority to elevate in the first place. Arecipients:write-only attempt against a payout-eligible recipient gets404 recipient_not_found, identical to a genuinely nonexistent id — never a distinct signal that confirms one exists.
Checking token issuer restrictions — GET /v1/recipients/{id}/transferability
Some stablecoin issuers can restrict an address on-chain: USDC's contracts refuse any transfer to or from a blacklisted address, USDT's refuse transfers from one. Klappay reads that status straight from each token contract's own view function (isBlacklisted for USDC, isBlackListed/isBlocked for USDT) — never from a Klappay list or a third-party sanctions source — and reports it per pair. The full picture (why issuers do this, and what happens at each step of a charge) is in token restrictions.
ts
import { RecipientTransferabilitySchema } from '@klappay/types'
RecipientTransferabilitySchema.parse({
recipientId: 'rc_abc123',
address: '0x1111111111111111111111111111111111111111',
pairs: [
{ token: 'USDC', network: 'ethereum', receive: 'restricted', send: 'restricted' },
{ token: 'USDT', network: 'ethereum', receive: 'allowed', send: 'restricted' },
{ token: 'USDT', network: 'base', receive: 'allowed', send: 'unknown' },
],
})recipients:read scope; 404 recipient_not_found for an id that isn't a non-revoked recipient of your organization in the calling key's environment. pairs covers every token/network pair currently enabled for payments in that environment that matches the address family (EVM pairs for an 0x... address; empty for a TRON address today). Each TransferRestrictionStatus is:
allowed— the token contract doesn't restrict this address in this direction.restricted— the issuer currently restricts it, so the chain itself would refuse that transfer.unknown— no answer: the token has no such function on that network (USDT on Base/Optimism, both tokens on BNB), or the read failed or took over 1 second. Klappay never blocks anything onunknown.
receive is what POST /v1/charges gates on (422 recipient_transfer_restricted). send never blocks a charge — the address can still be paid — but funds it receives may stay stuck in it until the issuer lifts the restriction, which is worth telling the address's owner about. USDT's receive is always allowed for a configured pair, because its contracts never check the recipient. Results are cached for up to 5 minutes (an unknown for 30 seconds), so a change made by the issuer can take that long to show. Rate limited to 20 requests/min per tenant, on top of the general per-tenant limit.
Error codes
| Code | Status | Meaning |
|---|---|---|
recipient_not_found | 404 | No recipient with that id for your org/environment — or it exists but is payout-eligible and this key lacks recipients:manage_payout to revoke it |
recipient_transfer_restricted | 422 | On POST /v1/charges: the token issuer restricts an address the charge would pay from receiving on one or more accepted pairs — the message lists the pairs to drop |
rate_limited | 429 | More than 20 GET /v1/recipients/{id}/transferability calls in a minute for your organization |
recipient_not_found_in_split | 422 | A splitRecipients[].recipientId on POST /v1/charges didn't resolve — wrong id, wrong environment, or revoked |
insufficient_scope | 403 | Missing recipients:read/recipients:write/recipients:manage_payout for the action attempted |
conflicting_api_key_scopes | 403 | The key holds both charges:split_write and a recipients-registration scope — never allowed together, rejected before any route runs |
See docs/security.md (klap-core) finding-50 for the full security rationale behind every rule on this page.
