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.
This product is rolling out chain by chain. Verify 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 |
| 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 |
| 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 |
| 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}'