Non-custodial payouts (EVM)
API-first EVM payouts signed off-chain with EIP-712 — funds never leave your wallet until distribution.
Non-custodial payouts distribute an ERC-20 token to many recipients directly from your own wallet (the funder). Unlike mass payouts, there is no deposit to payzum: you create the order, sign an EIP-712 payload off-chain with the funder's key, submit the signatures, and payzum executes the distribution gaslessly on your behalf. Your funds stay in your wallet until the moment of distribution.
Non-custodial payouts are enabled per merchant by payzum support. Until your account is enabled, every /v1/nc-payout request returns 403 NC_PAYOUT_NOT_ENABLED. The product is also rolling out chain by chain: confirm the chains available to your merchant with payzum support before relying on it in production.
Order lifecycle
awaiting_signature → signed → distributing → completed | partial_failed
| Status | Description |
|--------|-------------|
| awaiting_signature | Order created; typed data returned. Waiting for you to submit signatures. |
| signed | Signatures accepted and verified. Distribution queued. |
| distributing | Batches being executed on-chain. |
| completed | All recipients paid. |
| partial_failed | Distribution stopped with some recipients unpaid — see the failure object. |
Endpoints
All endpoints require the x-api-key header with your merchant API key and share the standard 60 requests/minute per-merchant rate limit.
| Method | Path | Description |
|--------|------|-------------|
| POST | /v1/nc-payout | Create order (multipart form with recipients CSV) |
| POST | /v1/nc-payout/:id/submit | Submit EIP-712 signatures (JSON) |
| GET | /v1/nc-payout/:id | Fetch order status and progress |
Two differences from the mass-payout APIs: errors use the { "ok": false, "error": { "code": "SCREAMING_SNAKE", ... } } envelope (a code field, not the kind field used by UTXO/EVM mass payouts), and this family does not support the Idempotency-Key header — retry a failed create by calling it again and discarding duplicates yourself.
Create order
POST /v1/nc-payout accepts multipart/form-data.
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| chain | string | yes | polygon | arbitrum | optimism | base | bnb | avalanche |
| mode | string | yes | mainnet | testnet |
| tokenContract | string | yes | ERC-20 contract address (0x + 40 hex) |
| funder | string | yes | Your wallet address that holds the tokens and signs (0x + 40 hex) |
| tokenDecimals | integer | yes | Token decimals, 0–36 |
| tokenSymbol | string | no | Display symbol; defaults to USDC |
| csv | file | yes | CSV with header row; columns address, amount (decimal), optional label |
A successful create returns 201 with { "ok": true, "order": { ... }, "signing": { ... } }.
order fields
id (prefixed pzncp_), merchantId, status (awaiting_signature), chain, mode, tokenContract, tokenSymbol, tokenDecimals, funder, approveRequired.
signing fields
Everything you need to authorize the distribution off-chain:
| Field | Type | Description |
|-------|------|-------------|
| payoutId | string | On-chain payout identifier |
| root | string | Merkle root of the recipient set |
| salt | string | Commit salt |
| deadline | string | Signature deadline (Unix seconds), 72 hours after the order is created |
| sumRecipientsRaw | string | Sum of all recipient amounts, raw token units |
| feeRaw | string | payzum fee, raw units |
| sponsorFeeRaw | string | Gas-sponsorship fee, raw units |
| permitTotalRaw | string | Total the permit/approve must cover, raw units |
| payoutCommitTypedData | object | EIP-712 { domain, types, message } to sign with the funder key |
| permitTypedData | object | null | EIP-712 permit payload, or null when the token has no permit support |
| approveRequired | boolean | Whether an on-chain approve is needed instead of / before a permit |
| approveSpender | string | Spender address for the on-chain approve |
| approveAmountRaw | string | Amount to approve, raw units |
All raw amounts are integer strings in the token's smallest unit.
Create errors
| HTTP | error.code | Description |
|------|--------------|-------------|
| 400 | INVALID_REQUEST | A form field failed validation (field named in the error body) |
| 400 | INVALID_CSV | CSV parse or row validation failure (rowIndex in the error body) |
| 400 | EMPTY_RECIPIENTS | CSV contained no recipient rows |
| 400 | TOKEN_NOT_ALLOWED | The token contract is not allowed for this chain |
| 403 | CHAIN_DISABLED | Chain disabled by the operator kill-switch |
| 403 | NC_PAYOUT_NOT_ENABLED | Non-custodial payouts are not enabled for this merchant (also returned by submit and status) |
| 500 | INTERNAL | Unexpected server error |
| 503 | CONFIG_UNAVAILABLE | Chain configuration temporarily unavailable — retry later |
Signing the order
The funder wallet signs payoutCommitTypedData — and permitTypedData when it is not null — using standard EIP-712 typed-data signing. If approveRequired is true and no permit is available, send an on-chain approve(approveSpender, approveAmountRaw) transaction from the funder before submitting.
Example with ethers v6:
import { ethers } from "ethers";
const funder = new ethers.Wallet(FUNDER_PRIVATE_KEY);
const { signing } = createResponse; // from POST /v1/nc-payout
function splitSig(sig: string) {
const { v, r, s } = ethers.Signature.from(sig);
return { v, r, s };
}
const commit = signing.payoutCommitTypedData;
const commitSig = splitSig(
await funder.signTypedData(commit.domain, commit.types, commit.message),
);
let permitSig = null;
if (signing.permitTypedData) {
const p = signing.permitTypedData;
permitSig = splitSig(await funder.signTypedData(p.domain, p.types, p.message));
} else if (signing.approveRequired) {
// On-chain approve required before submit:
// erc20.approve(signing.approveSpender, signing.approveAmountRaw)
}
await fetch(`${PAYZUM_BASE}/v1/nc-payout/${order.id}/submit`, {
method: "POST",
headers: { "x-api-key": PAYZUM_API_KEY, "content-type": "application/json" },
body: JSON.stringify({ commitSig, permitSig }),
});Submit signatures
POST /v1/nc-payout/:id/submit accepts JSON:
{
"commitSig": { "v": 27, "r": "0x…64 hex", "s": "0x…64 hex" },
"permitSig": null
}permitSig has the same { v, r, s } shape as commitSig, or is null when no permit was issued. On success returns 200 with { "ok": true, "order": { "id", "status" } } and the order advances to signed.
Submit errors
| HTTP | error.code | Description |
|------|--------------|-------------|
| 400 | INVALID_JSON | Body is not valid JSON |
| 400 | INVALID_REQUEST | Schema validation failed (Zod issues in the error body) |
| 400 | PERMIT_SIG_REQUIRED | The order was created with a permit but permitSig is null |
| 400 | INVALID_COMMIT_SIGNATURE | The commit signature does not recover to the funder address |
| 403 | NC_PAYOUT_NOT_ENABLED | Non-custodial payouts are not enabled for this merchant |
| 404 | NOT_FOUND | Unknown order id for this merchant |
| 409 | BAD_STATE | Order is not in awaiting_signature |
| 500 | INTERNAL | Unexpected server error |
Fetch order status
GET /v1/nc-payout/:id returns 200 with { "ok": true, "order": { ... } } (or 404 NOT_FOUND). Order fields:
id, status, chain, mode, tokenContract, tokenSymbol, tokenDecimals, funder, payoutId, approveRequired (boolean), permitApplied (boolean), sumRecipientsRaw, feeRaw, sponsorFeeRaw, permitTotalRaw, distributedRaw (integer strings), progress — with totalRecipients, paidCount, totalBatches, batchesDone — failure ({ kind, message } or null), createdAt, updatedAt, expiresAt.
Poll until status is completed or partial_failed:
curl "$PAYZUM_BASE/v1/nc-payout/pzncp_abc123" \
-H "x-api-key: $PAYZUM_API_KEY" | jq '.order | {status, progress}'