Payouts

Connect your own payout provider, set up partner payout profiles, record exchange rates, and create, execute, cancel and schedule payout runs.

Embed
Preview of the card
<a href="https://referalli.com/docs/payouts"><img src="https://referalli.com/docs/_api/public/orgs/07f432f4-34b9-46a7-aaad-c0e395d2e4f9/spaces/2bf4f614-3630-4fbf-85f9-5ecafa6b7d97/pages/0f7af687-7ae3-4fac-91f4-1a7b612da7ff/social-card.svg?v=3e7ae33eec8b" alt="Payouts" width="600" height="315"></a>

A payout run collects every partner's approved, unpaid commission, turns it into one payout per partner, and sends it through a payout provider. Referalli never holds funds: payouts move money from the provider account your org connects. Commissions become paid in the ledger only after the provider confirms the money moved.

If you pay partners yourself, for example as account credit, you don't need payout runs: settle the lines instead. Settled lines never enter a payout run and aren't in the 1099 export.

1. Connect a payout provider

Store your own provider credentials with PUT /v1/payout-connections/{provider}. They are encrypted at rest and never returned by the API.

curl -X PUT https://referalli.com/v1/payout-connections/paypal \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"clientId": "AYx...", "secret": "EFk...", "env": "live"}'
# { "provider": "paypal", "env": "live", "status": "active" }

provider

Body fields

Pays partners with method

Settlement

paypal

clientId, secret, env

paypal

async (webhook)

stripe

secretKey, env

stripe_connect, ach

immediate

circle

apiKey, walletId, defaultChain (optional), env

crypto (stablecoins such as USDC)

async (webhook)

wise

apiToken, profileId, env

wise

async (webhook)

payoneer

clientId, secret, programId, env

payoneer

async (webhook)

tremendous

apiKey, fundingSourceId, campaignId (optional), env

tremendous

immediate

env is sandbox or live. It defaults to sandbox for every provider except stripe, which defaults to live. Sending PUT again replaces the stored credentials.

GET /v1/payout-connections lists provider, env, status and connectedAt. DELETE /v1/payout-connections/{provider} removes one. All three require the owner, admin or finance role.

Connect a provider before paying with its method

If a partner's payout method has no connected provider, that payout fails with an error such as No PayPal connection: connect PayPal under Payments to pay this partner. Its status is failed, a payout.failed webhook fires, and its commissions stay approved, so the next run picks them up again once you connect the provider. Nothing is marked paid unless money moved. The csv method is the deliberate manual option: it marks payouts sent so you can pay them yourself outside Referalli.

2. Set up each partner

A partner is paid only when they have a payout profile and a tax form on file.

Payout profile

curl -X PUT https://referalli.com/v1/partners/ptr_.../payout-profile \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{
    "method": "paypal",
    "details": { "paypalEmail": "dana@example.com" },
    "payoutCurrency": "usd",
    "minThresholdMinor": 5000
  }'

Field

Default

Notes

method

required

paypal, wise, stripe_connect, ach, crypto, payoneer, tremendous or csv.

details

{}

Recipient details for the method (below).

payoutCurrency

usd for a new profile

Currency the partner is paid in. Omit it to keep the stored value.

minThresholdMinor

5000 for a new profile

Skip the partner until their payable total reaches this amount. Omit it to keep the stored value.

PUT replaces method and details. payoutCurrency and minThresholdMinor are optional: when you leave one out, the profile keeps its stored value, and the defaults above apply only when the partner has no profile yet. So re-saving a profile, by you or by the partner in the portal, never resets a threshold you set.

Recipient details each method reads:

Method

details keys

paypal

paypalEmail (or email, or receiver)

stripe_connect, ach

stripeAccountId (or destination), the partner's Stripe connected account

crypto

walletAddress (or address), and optionally chain (defaults to the connection's defaultChain, else ETH)

wise

wiseRecipientId (or targetAccount)

payoneer

payoneerId (or payeeId)

tremendous

email (or tremendousEmail), and optionally name

csv

none

Partners can set their own profile in the portal (PUT /v1/partner/orgs/{orgId}/payout-profile, same method, details and optional payoutCurrency fields). Partners can't set minThresholdMinor; their call keeps whatever threshold the profile has (5000 for a new one), and an omitted payoutCurrency keeps the stored currency (usd for a new one). If a partner picks a currency other than the one their commissions are in, the run needs an exchange rate for that pair from your org, or skips them with missing_fx_rate (see Exchange rates). That endpoint rejects a profile the method could never pay with 400, for example paypal payouts need a PayPal email or wise payouts need a Wise recipient id. PayPal and Tremendous details must be email addresses; the others must be non-empty. If the partner has two-factor sign-in on, the call also needs their current code in x-totp-code (see Authentication). The merchant PUT above does not validate details.

GET /v1/partners/{id}/payout-profile returns the method, currency, threshold and tax form status; it does not return details. It answers 404 no payout profile when the partner has none.

Change only the threshold

To change a partner's minimum without touching their method or details, use PATCH:

curl -X PATCH https://referalli.com/v1/partners/ptr_.../payout-profile \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"minThresholdMinor": 2500}'
# { "partnerId": "ptr_...", "method": "paypal", "payoutCurrency": "usd", "minThresholdMinor": 2500 }

minThresholdMinor is an integer of 0 or more, in the partner's payoutCurrency. The partner must already have a profile, or the call returns 404 no payout profile. It requires the owner, admin or finance role and is recorded in the audit log.

Tax form

curl -X POST https://referalli.com/v1/partners/ptr_.../tax-form \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"type": "w9", "data": {"name": "Dana Example", "tin": "123-45-6789", "address": "1 Main St, Springfield"}}'

type

Required data fields

w9

name, tin (at least 4 characters), address

w8ben

name, country, address

w8bene

name, country, address, and optionally entityType

Partners can also complete these steps themselves in the partner portal.

Identity checks (optional)

Set PATCH /v1/org with { "payoutKycRequired": true } to pay only partners whose identity is verified and who are not flagged by sanctions screening.

3. Create a payout run

Approve any lines whose hold has passed with POST /v1/commissions/approve-due (see the ledger); scheduled runs do this for you. Then:

curl -X POST https://referalli.com/v1/payout-runs \
  -H "Authorization: Bearer $KEY"
{
  "runId": "prun_...",
  "payouts": [
    {
      "payoutId": "pout_...",
      "partnerId": "ptr_...",
      "amountMinor": 6480,
      "currency": "usd",
      "rail": "paypal",
      "fxApplied": [],
      "invoice": { "number": "SBI-2026-14", "issuedAt": "2026-10-21T15:00:00.000Z", "lines": [ ... ], "vatNote": "" }
    }
  ],
  "skipped": [{ "partnerId": "ptr_...", "reason": "missing_tax_form" }]
}

Creating a run moves no money. It builds a draft with one pending payout per eligible partner and a self-billing invoice for each.

A run only takes lines that are approved, not yet paid and not already in another payout that is pending (created but not executed) or submitted (waiting for an async provider). So two runs never pay the same line, and a draft run holds its lines until you execute or cancel it. Lines from a failed or cancelled payout are free again for the next run.

Partners with approved lines are left out, with a reason, when:

reason

Meaning

partner_inactive

The partner is suspended, banned or archived in your org, or a Referalli platform admin has flagged their account. Their approved balance is held, not forfeited: set the partner back to active (PATCH /v1/partners/{id} with { "status": "active" }) and the next run pays it, including what a suspended partner earned while suspended. A banned or flagged partner earned nothing while blocked, so there is nothing extra to release. A flagged account stays skipped until the platform lifts the flag.

no_profile

No payout profile.

missing_tax_form

No tax form submitted.

kyc_unverified

payoutKycRequired is on and the partner is not verified.

sanctions_hold

payoutKycRequired is on and the partner is flagged by sanctions screening.

missing_fx_rate

Some of the partner's commission is in a currency other than their payoutCurrency, and your org has no rate for that pair (below). Only this partner is skipped; the rest of the run goes ahead.

below_threshold

Payable total, after conversion, is under minThresholdMinor.

Exchange rates

Commissions in a currency other than the partner's payoutCurrency are converted at an exchange rate your org records. The conversion is recorded on the payout in fxApplied (from, to, rate, ts), and each converted amount is rounded to the nearest minor unit.

curl -X POST https://referalli.com/v1/fx-rates \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"base": "eur", "quote": "usd", "rate": "1.0825"}'
{ "base": "eur", "quote": "usd", "rate": "1.0825", "effectiveAt": "2026-10-21T15:00:00.000Z", "source": "manual", "scope": "org" }

Field

Rules

base

Three-letter currency code the commission was earned in. Case doesn't matter; it is stored lowercase.

quote

Three-letter currency code the partner is paid in. Must differ from base.

rate

How many quote units one base unit buys. A positive decimal of up to 12 digits before and after the point, sent as a string ("1.0825") or a plain JSON number. It is stored exactly, never as a float.

effectiveAt

Optional ISO 8601 timestamp with offset. Defaults to now.

A run uses the latest rate for the exact base → quote pair whose effectiveAt is at or before the run. A eur → usd rate is not used for usd → eur; record each direction you need. Recording a new rate never changes payouts already created.

GET /v1/fx-rates lists up to 500 rates, optionally filtered with ?base= and ?quote=: your org's rates first, then the platform ones, each sorted by pair with the newest rate first. Each item has scope: org for rates you recorded, platform for the sample rates that ship with Referalli.

Production uses only your rates

In production, payouts and the 1099 export convert only at rates your org recorded. The platform sample rates are a fallback for development and testing only (a deployment can override this with FX_PLATFORM_FALLBACK=on or off). If you pay partners in the currency you bill in, you need no rates at all.

Both calls require the owner, admin or finance role. Recording a rate is written to the audit log.

The daily rate feed

Instead of recording every rate yourself, you can turn on the daily feed of European Central Bank reference rates. It's public data and needs no key.

curl -X PUT https://referalli.com/v1/org/fx-feed \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"enabled": true}'
# { "enabled": true, "source": "ecb", "synced": 6 }

Turning it on fetches the latest rates straight away; after that they update daily. Rates are stored for the currencies your org uses (those of its commission lines and partners' payout currencies), in both directions, with source: "ecb" and effectiveAt set to the ECB's date. Cross rates go through the euro and are computed exactly, to 10 decimal places. GET /v1/org/fx-feed returns enabled and lastRateDate; POST /v1/fx-rates/sync fetches now.

A run picks the rate for a pair in this order:

  1. your latest manual rate (POST /v1/fx-rates), which pins the pair over the feed,

  2. the latest feed rate, while the feed is on,

  3. in development only, the platform sample rates.

To hand a pinned pair back to the feed, clear its override: POST /v1/fx-rates/clear-override with {"base": "usd", "quote": "eur"}. Cleared rates keep their history and show retiredAt. With the feed off (the default), feed rates are ignored and only your own rates are used. All of these calls require the owner, admin or finance role and are audited.

4. Execute the run

curl -X POST https://referalli.com/v1/payout-runs/prun_.../execute \
  -H "Authorization: Bearer $KEY"
# { "sent": 12, "submitted": 3, "failed": 1 }

Each pending payout in the run is sent through the rail for its method, using your connected credentials:

Payout status

Meaning

Ledger

sent

An immediate rail (Stripe, Tremendous, CSV) moved the money, or an async provider later confirmed it.

paid events written, payout.sent emitted.

submitted

An async provider (PayPal, Circle, Wise, Payoneer) accepted it; the outcome arrives later by webhook.

Unchanged until confirmed. payout.submitted emitted. The lines stay out of new runs.

failed

The provider rejected it. failureReason says why.

Unchanged, so the commission is payable again in the next run. payout.failed emitted.

The run's status is processing while any payout is still submitted, failed if payouts failed and none were sent, and completed otherwise. Executing a cancelled run sends nothing and returns { "sent": 0, "submitted": 0, "failed": 0 }.

Async settlement

Async providers report the final outcome to POST /webhooks/payouts/{provider} (paypal, circle, wise or payoneer). Each request must carry x-payout-signature: sha256=<hex HMAC-SHA256 of the raw body>, computed with the payout webhook secret configured on the Referalli deployment; unsigned or mis-signed requests get 401, and if no secret is configured the endpoint answers 503. A confirmed payout moves to sent and writes its paid events exactly once; a failed one moves to failed.

Cancel a run

A run you created but don't want to send can be cancelled:

curl -X POST https://referalli.com/v1/payout-runs/prun_.../cancel \
  -H "Authorization: Bearer $KEY"
# { "cancelled": 4 }

Every pending payout in the run becomes cancelled and emits payout.cancelled, and the run's status becomes cancelled. No money moves and no ledger events are written: the lines go back to being approved and unpaid, and the next run picks them up. cancelled is the number of payouts cancelled.

Response

When

200 { "cancelled": n }

The run had no payout submitted or sent. Cancelling a run that is already cancelled returns { "cancelled": 0 }.

404 not_found

No run with that id in your org.

409 conflict

Some payout in the run is already submitted or sent. The message is payout run has payouts submitted or sent; money is in flight and it cannot be cancelled.

Cancelling requires the owner, admin or finance role and is recorded in the audit log.

Scheduled runs

An org can have runs created and executed automatically on a schedule. Pick one of four kinds:

kind

Fields

Runs on

monthly

dayOfMonth (1–31)

That day each month; 29 to 31 fall on the last day of shorter months.

weekly

dayOfWeek (0 = Sunday … 6 = Saturday)

That weekday every week.

twice_monthly

days: two days, for example [1, 15]

Both days each month, clamped like monthly.

custom

dates: up to 366 dates as YYYY-MM-DD

Exactly those days.

curl -X PUT https://referalli.com/v1/org/payout-schedule \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"schedule": {"kind": "weekly", "dayOfWeek": 5}}'
# { "schedule": { "kind": "weekly", "dayOfWeek": 5 }, "nextRunDate": "2026-10-23" }

{"schedule": null} turns scheduled runs off. The original body, {"dayOfMonth": 1} (or null), still works and means a monthly schedule. Setting a schedule requires the owner, admin or finance role, and every change is audited. All days are UTC.

Per-program schedules

A program follows the org schedule unless it has its own:

curl -X PUT https://referalli.com/v1/programs/$PROGRAM/payout-schedule \
  -H "Authorization: Bearer $KEY" \
  -H "content-type: application/json" \
  -d '{"schedule": {"kind": "twice_monthly", "days": [1, 15]}}'

schedule takes the same kinds, {"kind": "off"} to never pay that program automatically, or null to follow the org schedule again. GET /v1/org/payout-schedule returns the org schedule, its nextRunDate, and each program's override and nextRunDate.

What a scheduled run does

The API checks schedules every hour. On a day when any schedule is due it:

  1. approves every line whose hold has passed, exactly like POST /v1/commissions/approve-due,

  2. creates one run for the programs whose schedule is due (lines from other programs wait for their own day), with the same eligibility and skip reasons as above, and

  3. executes it through your org's own payout connections, the same rails as a manual run.

There is at most one scheduled run per org per UTC day, however many times the check runs. A run with no eligible partners is still created, with no payouts. If a run fails before it is created, the next hourly check retries it. POST /v1/payout-runs/run-scheduled runs this for your own org straight away if a schedule is due today, and returns the result per run (orgId, runId, programIds, payouts, sent, failed).

Payout records

  • GET /v1/payout-runs lists runs with status (draft, processing, completed, failed or cancelled), summary, createdAt and executedAt. summary holds payouts and skipped from creation, sent, submitted and failed after execution, and cancelled after a cancel.

  • GET /v1/payouts?partnerId=… lists payouts, newest first and paginated (the latest 200 under 2026-10-21), with id, runId, partnerId, amountMinor, currency, status (pending, submitted, sent, failed or cancelled), rail, railRef, failureReason and sentAt.

  • GET /v1/payouts/{id}/invoice returns the self-billing invoice for one payout.

  • GET /v1/tax/1099?year=2026 returns one entry per W-9 partner with payouts sent that year. To file the forms and deliver them to partners, see Tax.

Each 1099 entry has partnerId, taxFormType, totalPaidUsdMinor, fxMissingCurrencies and requiresForm. Payouts in another currency are converted to USD at your org's latest rate on or before the end of the year. If a currency has no rate, its payouts are left out of totalPaidUsdMinor, the currency is listed in fxMissingCurrencies, and requiresForm is true so you review that partner by hand. Otherwise requiresForm is true at $600 (60000) or more.

Creating, executing and cancelling runs requires the owner, admin or finance role, as does the 1099 export.

Updated