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}'