Skip to content

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 payout back to false — recipients:write can never grant or preserve a prior payout approval by itself.
  • Revoking a recipient that currently has payout: true requires recipients:manage_payout, not just recipients: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. A recipients:write-only attempt against a payout-eligible recipient gets 404 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 on unknown.

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 ​

CodeStatusMeaning
recipient_not_found404No 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_restricted422On 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_limited429More than 20 GET /v1/recipients/{id}/transferability calls in a minute for your organization
recipient_not_found_in_split422A splitRecipients[].recipientId on POST /v1/charges didn't resolve — wrong id, wrong environment, or revoked
insufficient_scope403Missing recipients:read/recipients:write/recipients:manage_payout for the action attempted
conflicting_api_key_scopes403The 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.

Public docs for @klappay/types, mirrored from this package's own docs/ folder.