# Introduction (/docs/v1)



<Callout title="API v2 is available">
  v1 keeps working until its sunset date is announced. Build new integrations on v2, and see [Migrate from v1 to v2](/docs/v2/guides/migrate-to-v2) to move an existing one.
</Callout>

The Talentir API lets you integrate payouts into your own product. Talentir runs the payments, carries the liability, and handles recipient verification, so you do not build banking integrations or a ledger.

## What you can do [#what-you-can-do]

* **Create payouts**: send payments to creators using social media handles (`@username`), email addresses, or wallet addresses.
* **Mint hosted sessions**: generate Talentir-hosted URLs for payout claiming, payout approval, KYB verification, and spending allowance — optionally under your own white-label branding.
* **Manage webhooks**: receive real-time notifications for payout status changes.
* **Access team information**: read your team details, members, balances, and deposit instructions.

## Base URLs [#base-urls]

| Environment | Base URL                              |
| ----------- | ------------------------------------- |
| Production  | `https://www.talentir.com/api/v1`     |
| Sandbox     | `https://sandbox.talentir.com/api/v1` |

The sandbox is a standalone environment with its own persistent database. Sign up self-service; your teams, API keys, and webhooks stay put between visits. All external providers (banking, PayPal, blockchain, compliance screening) are simulated: no real money moves, and payouts settle instantly and synchronously. Business verification (KYB) completes with one click, wallets are simulated, and every payout method has a passing and a failing test scenario (see [Create a payout](/docs/v1/guides/create-a-payout)).

## How to use these docs [#how-to-use-these-docs]

<Cards>
  <Card description="Send your first payout from the sandbox in a few minutes." href="/docs/v1/quickstart" title="Quickstart" />

  <Card description="Team API keys for your own team, OAuth 2.1 with PKCE for platforms." href="/docs/v1/authentication/api-keys" title="Authentication" />

  <Card description="Create payouts, mint hosted sessions, approve, and receive webhooks." href="/docs/v1/guides/create-a-payout" title="Guides" />

  <Card description="Every endpoint, parameter, and response schema, generated from the live spec." href="/docs/v1/reference/payouts/payout.create" title="API Reference" />
</Cards>

* Each endpoint page has a **Test** button that opens the Scalar API client for the endpoint.
* The machine-readable specification lives at [`/api/v1/spec.json`](https://www.talentir.com/api/v1/spec.json). Agent-readable page dumps are at [`/docs/llms.txt`](/docs/llms.txt) and [`/docs/llms-full.txt`](/docs/llms-full.txt).


# Quickstart (/docs/v1/quickstart)



This walkthrough uses the sandbox, where no real money moves and payouts settle instantly.

## 1. Get an API key [#1-get-an-api-key]

1. Sign up at [sandbox.talentir.com](https://sandbox.talentir.com) and create a team.
2. Go to **Dashboard → Integrations → API keys** and create a key.
3. Send it as a Bearer token on every request.

```bash
export TALENTIR_API_KEY="tal_..."
export BASE_URL="https://sandbox.talentir.com/api/v1"
```

## 2. Create a payout [#2-create-a-payout]

```bash
curl -X POST "$BASE_URL/payout" \
  -H "Authorization: Bearer $TALENTIR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "description": "Payment for YouTube channel campaign",
    "creatorHandle": "@mrbeast",
    "verificationMethod": "youtube-channel",
    "payoutAmount": "100.00",
    "currency": "EUR",
    "customId": "campaign-42"
  }'
```

The response contains the payout `id`, its `status` (`created`), and an `action` field that tells you whether the payout was created or updated (payouts are upserted by `customId` — see [Idempotency](/docs/v1/concepts/idempotency)).

## 3. Send the recipient a claim link [#3-send-the-recipient-a-claim-link]

Mint a hosted session URL and send it to the person being paid:

```bash
curl -X POST "$BASE_URL/session/payout" \
  -H "Authorization: Bearer $TALENTIR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "payoutId": "<payout id from step 2>",
    "redirectUrl": "https://yourapp.com/done"
  }'
```

The returned `url` is an opaque Talentir-hosted claim screen. The recipient signs in or signs up with their own email and claims the payout. Treat the URL as opaque and do not parse it.

## 4. Check the status [#4-check-the-status]

```bash
curl "$BASE_URL/payout/<payout id>" \
  -H "Authorization: Bearer $TALENTIR_API_KEY"
```

In the sandbox, a claimed payout settles instantly, so you can watch it move through the [payout lifecycle](/docs/v1/concepts/payout-lifecycle) right away. For production integrations, subscribe to [webhooks](/docs/v1/guides/webhooks) instead of polling.

## Next steps [#next-steps]

* [Approve and execute payouts](/docs/v1/guides/approve-and-execute-payouts) — payouts start out pending; someone or something must approve them.
* [OAuth 2.1 with PKCE](/docs/v1/authentication/oauth) — for platform integrations that act on behalf of connected customer teams.
* [Set up webhooks](/docs/v1/guides/webhooks) — real-time payout status notifications.


# Introduction (/docs/v2)



The Talentir API adds payouts to your product. You create a payout, and Talentir does the rest: it verifies the recipient, issues the invoice, moves the money, and carries the liability. You do not build banking integrations or a ledger.

There are two ways to pay. Pick the one that matches what you know about the recipient.

## Pay with a payout link [#pay-with-a-payout-link]

You know the recipient's email address and nothing else. Create a payout for that email and hand the recipient a Talentir-hosted link. The recipient signs in with a one-time code, enters their own bank account or PayPal, and claims the money. It is a hosted checkout page in reverse: Talentir collects the payment details and runs verification, so your product never stores them.

1. `POST /payouts` with `recipient: { "type": "email", "email": "jane@example.com" }` and `andThen: "approve_and_send_claim_link"`. Talentir emails the link.
2. Or mint the link yourself with `POST /sessions` and `type: "payout_claim"`, then redirect the recipient to it from your product. The page can carry your own branding.
3. `payout.completed` arrives on your webhook when the money has left.

Start with [Pay with a payout link](/docs/v2/guides/pay-with-payout-link).

## Pay through the API only [#pay-through-the-api-only]

You already hold the recipient's details, or you collect them in your own form. Save the recipient as a counterparty, then push payouts to the saved bank account, PayPal, or crypto wallet. No link, no recipient sign-in, no Talentir screen.

1. `GET /countries` returns the tax fields a counterparty in each country needs, and which combinations are accepted. `GET /payment-method-types` returns the rails with the currencies and assets they settle in. Build your form from them instead of hardcoding fields.
2. `POST /counterparties` saves the recipient.
3. `POST /payouts` with `recipient: { "type": "counterparty", "counterpartyId": "counterparty_…" }` and `andThen: "approve_and_pay"` creates, approves, and pays in one request.
4. `payout.completed` or `payout.failed` arrives on your webhook.

Start with [Pay directly](/docs/v2/guides/pay-directly).

Both ways use the same payout resource, so you can mix them: push money to recipients you know, and send a link to everyone else.

## Also in the API [#also-in-the-api]

* **Hosted sessions** for payout approval, business verification (KYB), and spending allowance, optionally under your white-label branding.
* **Webhooks** with signed, real-time events for payout, counterparty, and transaction changes.
* **Team information**: your team and its members, and your accounts with balances and deposit instructions.
* **Transactions**: every movement on your accounts, tied to the payouts it settles.

## API versions [#api-versions]

**v2** (`/api/v2`) is the current API and the one these docs describe. **v1** (`/api/v1`) still works but will be sunset, so build new integrations on v2 and see [Migrate from v1 to v2](/docs/v2/guides/migrate-to-v2) for existing ones.

## Changes without a new version [#changes-without-a-new-version]

Talentir can make these changes to v2 at any time, without notice:

* Add endpoints.
* Add fields to response objects, webhook events, and error `details`.
* Add optional fields to request bodies, and optional query parameters.
* Add values to enums that the reference marks as open, such as `status` or `paymentMethod.type`, and add event types.

Build your client so that these changes do not break it:

* Ignore response fields that you do not know. The schemas in the reference and in [`/api/v2/spec.json`](https://www.talentir.com/api/v2/spec.json) list the fields that exist today and mark objects as closed (`additionalProperties: false`). A response can have more fields, so do not validate responses strictly against these schemas.
* Treat an unknown value of an open enum as a fallback case.

## Base URLs [#base-urls]

| Environment | Base URL                              |
| ----------- | ------------------------------------- |
| Production  | `https://www.talentir.com/api/v2`     |
| Sandbox     | `https://sandbox.talentir.com/api/v2` |

Replace `v2` with `v1` for the legacy API.

The sandbox is a standalone environment with its own persistent database. Sign up self-service; your teams, API keys, and webhooks stay put between visits. All external providers (banking, PayPal, blockchain, compliance screening) are simulated: no real money moves, and payouts settle instantly and synchronously. Business verification (KYB) completes with one click, wallets are simulated, test money funds the balance from the dashboard or with [`POST /team/sandbox-deposit`](/docs/v2/reference/team/team.sandboxDeposit), and every payout method has a passing and a failing test scenario (see [Pay with a payout link](/docs/v2/guides/pay-with-payout-link#sandbox-test-scenarios)).

## How to use these docs [#how-to-use-these-docs]

<Cards>
  <Card description="Send your first payout from the sandbox in a few minutes." href="/docs/v2/quickstart" title="Quickstart" />

  <Card description="Team API keys for your own team, OAuth 2.1 with PKCE for platforms." href="/docs/v2/authentication/api-keys" title="Authentication" />

  <Card description="Pay with a link or directly, approve payouts, build a platform integration, and receive webhooks." href="/docs/v2/guides/pay-with-payout-link" title="Guides" />

  <Card description="Every v2 endpoint, parameter, and response schema, generated from the contract." href="/docs/v2/reference/payouts/payouts.create" title="API reference" />
</Cards>

* Each endpoint page has a **Test** button that opens the Scalar API client for the endpoint.
* The machine-readable specifications live at [`/api/v1/spec.json`](https://www.talentir.com/api/v1/spec.json) and [`/api/v2/spec.json`](https://www.talentir.com/api/v2/spec.json). Agent-readable page dumps are at [`/llms.txt`](/docs/llms.txt) and [`/llms-full.txt`](/docs/llms-full.txt).


# Quickstart (/docs/v2/quickstart)



You create a payout through the API, then approve it and send the recipient a claim link in the dashboard. The sandbox is used throughout: no real money moves, and payouts settle instantly.

## 1. Set up the sandbox [#1-set-up-the-sandbox]

1. Sign up at [sandbox.talentir.com](https://sandbox.talentir.com) and create a team.
2. Complete business verification with the **Complete instantly** button.
3. Open **Wallet** and click **Deposit 1,000 EUR**, or later call [`POST /team/sandbox-deposit`](/docs/v2/reference/team/team.sandboxDeposit) with your key. Approval needs a funded balance.
4. Go to **Integrations → API keys** and create a key.

```bash
export TALENTIR_API_KEY="tal_..."
export BASE_URL="https://sandbox.talentir.com/api/v2"
```

## 2. Create a payout [#2-create-a-payout]

```bash
curl -X POST "$BASE_URL/payouts" \
  -H "Authorization: Bearer $TALENTIR_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "description": "Payment for September campaign",
    "amount": "100.00",
    "currency": "EUR",
    "recipient": { "type": "email", "email": "jane@example.com" }
  }'
```

The response is the payout in status `created`. Nothing moves yet: a payout waits for your team's approval. Copy its `id` for the next steps:

```bash
export PAYOUT_ID="payout_..."
```

## 3. Approve it and send the claim link in the dashboard [#3-approve-it-and-send-the-claim-link-in-the-dashboard]

Open **Payouts** in the dashboard, select the payout, and click **Approve**. The amount is now reserved against your balance and the status is `approved`. The dialog then asks how the money reaches the recipient: choose **Send claim link** and confirm.

Talentir emails the recipient. They sign in with a one-time code, choose a payout method, and claim the money. Use your own email address as the recipient to walk through the claim screen yourself.

## 4. Check the status [#4-check-the-status]

```bash
curl "$BASE_URL/payouts/$PAYOUT_ID" \
  -H "Authorization: Bearer $TALENTIR_API_KEY"
```

In the sandbox, a claimed payout settles instantly, so `status` is `completed` right after the claim.

## Next steps [#next-steps]

* [Pay with a payout link](/docs/v2/guides/pay-with-payout-link) — every payout field, sending the link yourself, and the sandbox failure scenarios.
* [Approve payouts](/docs/v2/guides/approve-payouts) — approve on a hosted screen or through the API instead of the dashboard.
* [Pay directly](/docs/v2/guides/pay-directly) — push payouts to a saved bank account, PayPal, or crypto wallet, without a claim link.
* [Set up webhooks](/docs/v2/guides/webhooks) — real-time payout status notifications instead of polling.
* [Transactions](/docs/v2/concepts/transactions) — see the money of the payout leave your account, and reconcile your balance.


# Team API keys (/docs/v1/authentication/api-keys)



All endpoints require Bearer token authentication. The simplest token is a team API key.

## Create a key [#create-a-key]

Go to **Dashboard → Integrations → API keys** and create a key. Store it in your secret manager; it is shown once.

## Use it [#use-it]

Send the key on every request:

```bash
curl "https://www.talentir.com/api/v1/team" \
  -H "Authorization: Bearer $TALENTIR_API_KEY"
```

API key access is scoped to the team that owns the key. To act on behalf of other teams (your customers), use [OAuth 2.1](/docs/v1/authentication/oauth) instead.

## Scopes [#scopes]

API keys carry the default scopes: read and write payouts, webhooks, sessions, and team information. The `payouts:approve` scope — creating payouts as pre-approved — is only granted to keys of teams with the `payout.api_approve` permission. See [Scopes and permissions](/docs/v1/concepts/scopes-and-permissions).

## Good practice [#good-practice]

* Keys are environment-specific: a sandbox key does not work in production.
* Rotate keys from the dashboard; deleting a key revokes it immediately.
* Never embed keys in client-side code. Calls belong on your server.


# OAuth 2.1 with PKCE (/docs/v1/authentication/oauth)



For platform integrations, use the OAuth 2.1 authorization code flow with PKCE: you redirect your customer to Talentir's hosted authorization screens, they sign in (or sign up), select or create their team, and consent. Your server then exchanges the code for tokens scoped to that team.

## Get an OAuth client [#get-an-oauth-client]

**Create it in the dashboard (recommended):** go to **Settings → OAuth Clients** in your Talentir team dashboard, enter a name and your callback URL(s), and you receive a `client_id` and `client_secret` (shown once, with copy and download). Clients created there are *attributed to your team*, which unlocks the partner-program features: teams created through your authorize flow are credited to your platform, and hosted sessions can carry your white-label branding.

**Deprecated — dynamic client registration (RFC 7591)** via the `registration_endpoint` still works (it needs no dashboard or signup, and AI/MCP tooling relies on it), but it is deprecated for platform integrations and may be restricted in the future. Dynamically registered clients are anonymous — no attribution, no platform branding — and cannot be managed or edited afterwards.

## Discovery [#discovery]

```
GET /.well-known/oauth-authorization-server/api/auth
```

This returns the `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, and `jwks_uri`.

## Requirements [#requirements]

* `code_challenge` / `code_challenge_method=S256` — PKCE is mandatory.
* `resource=<base_url>/api/v1` on **both** `/authorize` and `/token`, so the token's `aud` claim binds to the API v1 resource (for production: `https://www.talentir.com/api/v1`).
* The user must select a team during authorization; the chosen team is encoded in the `https://talentir.com/oauth/team_id` claim.
* Include `offline_access` in the scope list to receive a refresh token.

## Authorization hints [#authorization-hints]

Add any of these query parameters to the `/authorize` request to pre-fill the hosted screens when you already know who is connecting. They are *hints only* — the authenticated account and the selected team are always verified server-side.

| Parameter    | Value                               | Effect                                                                                                                                                                                                       |
| ------------ | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `login_hint` | Email address                       | Pre-fills the email and emails the one-time sign-in code automatically (social/passkey sign-in is hidden). If the user is already signed in with a different email, they are asked to switch accounts first. |
| `team_hint`  | A team `id` (UUID) from `GET /team` | Pre-selects that team on the team step. Ignored if the signed-in user is not a member of it.                                                                                                                 |
| `team_name`  | String                              | Pre-fills the suggested name when a new user who has no team yet is prompted to create one.                                                                                                                  |

Example (URL-encode the values in practice):

```
https://www.talentir.com/api/auth/oauth2/authorize?response_type=code&client_id=<client_id>
  &redirect_uri=<redirect_uri>&scope=team:read payouts:write
  &code_challenge=<challenge>&code_challenge_method=S256
  &resource=https://www.talentir.com/api/v1
  &login_hint=creator@example.com
  &team_hint=4399dd07-cf1b-414b-bfbc-91a88dd73ec5
```

Teams created during your authorize flow are automatically attributed to your platform when your OAuth client was created in the dashboard. Partner-program enrollment (referral terms, white-label) is handled by Talentir — contact us to enroll.

## Scopes [#scopes]

See [Scopes and permissions](/docs/v1/concepts/scopes-and-permissions) for the full table.


# Breaking changes (/docs/v1/concepts/breaking-changes)



Every breaking change is announced here before it takes effect. A deprecated field keeps working until its removal date. Additive changes — new endpoints, new optional fields, new enum values — are not breaking and are not listed, so write clients that ignore fields they do not know.

## Scheduled removals [#scheduled-removals]

| Date       | Endpoint    | Change                                                                   |
| ---------- | ----------- | ------------------------------------------------------------------------ |
| 2026-09-23 | `GET /team` | `balances[].depositInfo` is removed. Use the top-level `deposits` array. |

## Deprecated, no removal date yet [#deprecated-no-removal-date-yet]

| Deprecated since | Endpoint                                           | Field                                                            | Use instead                                         |
| ---------------- | -------------------------------------------------- | ---------------------------------------------------------------- | --------------------------------------------------- |
| 2025-10-27       | `GET /payout/{id}`, `GET /payouts`                 | `uuid`                                                           | `id`                                                |
| 2025-12-04       | `GET /team`                                        | `teamUuid`                                                       | `id`                                                |
| 2026-02-11       | `POST /payout`, `GET /payout/{id}`, `GET /payouts` | `handleType`                                                     | `verificationMethod`                                |
| 2026-06-21       | `GET /payout/{id}`, `GET /payouts`                 | `url`                                                            | A fresh claim URL from `POST /session/payout`       |
| 2026-07-28       | `POST /session/payout`                             | `whitelabel` (accepted but ignored)                              | `branding: "team"`                                  |
| 2026-07-28       | OAuth                                              | Dynamic client registration (RFC 7591) for platform integrations | An OAuth client created in Settings → OAuth Clients |


# Errors and retries (/docs/v1/concepts/errors-and-retries)



## Error format [#error-format]

Errors are JSON with a machine-readable `code`, an HTTP `status`, a human-readable `message`, and sometimes structured `data`:

```json
{
  "code": "INSUFFICIENT_BALANCE",
  "status": 422,
  "message": "Insufficient balance to approve this payout",
  "data": {
    "walletBalance": "50.00",
    "totalOpenAmount": "150.00",
    "currency": "EUR"
  }
}
```

## Common codes [#common-codes]

| Status | Code                      | Meaning                                                                                                 |
| ------ | ------------------------- | ------------------------------------------------------------------------------------------------------- |
| 401    | `UNAUTHORIZED`            | Missing or invalid token                                                                                |
| 403    | `FORBIDDEN`               | Token lacks a required scope or permission                                                              |
| 404    | `NOT_FOUND`               | Resource does not exist for the authenticated team                                                      |
| 409    | `CONFLICT`                | Update rejected, e.g. an immutable payout                                                               |
| 422    | `INPUT_VALIDATION_FAILED` | The request body failed validation; `message` is a readable summary and `data` carries per-field issues |
| 422    | `INSUFFICIENT_BALANCE`    | Pre-approved payout exceeds the team wallet balance                                                     |
| 500    | `INTERNAL_SERVER_ERROR`   | Unexpected server fault                                                                                 |

## Retry guidance [#retry-guidance]

* **4xx** errors are yours to fix — retrying the identical request fails again (except 429).
* **5xx** errors and network timeouts are safe to retry with exponential backoff. Payout creation is an upsert keyed on `customId`, so a retried create never duplicates a payout — see [Idempotency](/docs/v1/concepts/idempotency).
* Write clients that **ignore unknown fields**: additive changes (new endpoints, new optional fields, new enum values) are not breaking and happen without notice. See [Breaking changes](/docs/v1/concepts/breaking-changes).


# Idempotency (/docs/v1/concepts/idempotency)



`POST /payout` is an &#x2A;*upsert keyed on `customId`**. There is no separate idempotency-key header.

## How it works [#how-it-works]

Give every payout a stable `customId` from your own system (an order ID, a campaign line, an invoice number):

* If no payout with that `customId` exists for your team, one is created.
* If one exists, the request **updates** it instead of creating a duplicate.
* A deleted payout is final: it is never updated, and its `customId` stays taken. The same `customId` then fails.

The response tells you which happened:

```json
{
  "id": "0e4ba886-0bfe-4b6a-ae2e-d6d9d135dd1e",
  "customId": "campaign-42",
  "status": "created",
  "action": "updated"
}
```

So a timeout-and-retry loop is safe: send the same request again and you get the same payout back with `action: "updated"`.

## Locking a payout down [#locking-a-payout-down]

If the payout must not change after creation — for example when the request data comes from a signed source — create it with `payoutType: "manual-immutable"`. Source-of-truth fields then cannot be updated afterwards; an attempt returns HTTP 409.

## Look up by your own ID [#look-up-by-your-own-id]

`GET /payout/{id}` accepts either the Talentir `id` (UUID) or your `customId` — pass `id_type=custom_id` as a query parameter. See the generated **Payouts** reference for details.

## What is not idempotent [#what-is-not-idempotent]

Session minting (`POST /session/*`) returns a fresh URL each call; that is by design (URLs are stateless and disposable). Webhook subscription creation is not keyed — check `GET /webhook` before subscribing if you might retry.


# Payout lifecycle (/docs/v1/concepts/payout-lifecycle)



Every payout moves through a small state machine. Each transition emits a [webhook event](/docs/v1/guides/webhooks) whose payload matches the `GET /payout/{id}` schema.

## States [#states]

| Status      | Meaning                                                                                                                                    |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `created`   | The payout exists and is pending approval by the sending team. The recipient can already be invited to claim.                              |
| `approved`  | The sending team approved the payout (dashboard, approval session, or `preApproved` creation). Funds are reserved against the team wallet. |
| `requested` | The recipient claimed the payout and chose a payout method; the transfer has been requested from the payment provider.                     |
| `completed` | The current delivery attempt completed.                                                                                                    |
| `deleted`   | The payout was deleted before completion. Terminal.                                                                                        |
| `expired`   | The payout expired unclaimed. Terminal.                                                                                                    |

The happy path is `created` → `approved` → `requested` → `completed`.

## Notes [#notes]

* Approval order is not fixed: a recipient can claim before approval — the transfer waits until the payout is both approved and funded.
* A provider rejection after `requested` does not complete the payout: it stays `requested` while the failure is handled (in the sandbox you can force this with the failure triggers listed in [Create a payout](/docs/v1/guides/create-a-payout)).
* `availableOn` delays claimability, not creation: the payout exists immediately but can only be claimed from that date.
* Deleting (`DELETE /payout/{id}`) is only possible before the recipient requests the payout: `created`, `approved` and `expired` payouts can be deleted, `requested` and `completed` payouts cannot (HTTP 409).
* From `requested` on, `recipientInvoiceSource` says who wrote the document behind `recipientInvoice`: `self-billing` (Talentir issued it in the recipient's name) or `recipient-upload` (the recipient uploaded their own invoice while claiming).

## Retries [#retries]

When the provider returns a payout, it stays `requested` until a new attempt delivers it. Talentir Operations reviews each return first; until the review ends, the payout has no `retry` object. After the review, the payout has a `retry` object with `url`, `code`, and `reason`. `url` is the recipient's retry link: show it to the recipient while it is set. It is `null` while the new attempt runs, or when the sender must pay again. `code` says why the provider returned the payout: `account_closed`, `account_invalid`, `name_mismatch`, or `other`. New values can appear. `reason` is the same as text. After delivery completes, the status becomes `completed` and `retry` is omitted.

`paidOutAt` is omitted until delivery completes. Provider acceptance does not set this field.

The payout keeps its public ID and original requested `amount` and `currency`. `toMethod`, `toAsset`, and `requestedAt` describe the current delivery attempt.


# Scopes and permissions (/docs/v1/concepts/scopes-and-permissions)



Every token — team API key or OAuth access token — carries scopes that gate what it can do.

## Scopes [#scopes]

| Scope             | Grants                                                                                                                                                                                                              |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `team:read`       | Read team information                                                                                                                                                                                               |
| `payouts:read`    | List and read payouts                                                                                                                                                                                               |
| `payouts:write`   | Create payouts (kept pending; no approval/execution)                                                                                                                                                                |
| `payouts:approve` | Create payouts as pre-approved. **Restricted** — only granted to admin-provisioned OAuth clients and to API keys for teams with the `payout.api_approve` permission. Not available via dynamic client registration. |
| `webhooks:read`   | List webhook subscriptions                                                                                                                                                                                          |
| `webhooks:write`  | Create and delete webhook subscriptions                                                                                                                                                                             |
| `sessions:write`  | Mint hosted session URLs (granted by default)                                                                                                                                                                       |

For OAuth, include `offline_access` in the scope list to receive a refresh token.

## Which team does a token act on? [#which-team-does-a-token-act-on]

* A **team API key** is scoped to the team that owns the key.
* An **OAuth access token** is scoped to the team the user selected during authorization, carried in the `https://talentir.com/oauth/team_id` claim. For platforms this is usually a customer's team, not your own.

## Safety model [#safety-model]

`payouts:write` alone can never move money: created payouts stay pending until a team member approves them (see [Approve and execute payouts](/docs/v1/guides/approve-and-execute-payouts)). Money movement always requires either the restricted `payouts:approve` scope or an explicit human approval backed by the team's passkey wallet and daily allowance.


# Approve and execute payouts (/docs/v1/guides/approve-and-execute-payouts)



A payout created through the API starts out pending: it exists, the recipient can be notified, but no money moves until the payout is approved by the sending team. This split keeps the API safe by default — `payouts:write` can never move funds on its own.

## Option 1: approve in the dashboard [#option-1-approve-in-the-dashboard]

Team members with approval permission see pending payouts in the Talentir dashboard and approve them there. No integration work needed.

## Option 2: approval sessions [#option-2-approval-sessions]

Mint a hosted approval screen for a team member with `POST /session/approval` and a list of `payoutIds`. The member reviews and approves the batch on a Talentir-hosted page. See [Create hosted sessions](/docs/v1/guides/hosted-sessions).

This is the recommended pattern for platforms: your product shows "payouts ready", the customer clicks through to the hosted screen, reviews, and approves with their passkey.

## Option 3: pre-approved creation [#option-3-pre-approved-creation]

With the `payouts:approve` scope, `POST /payout` accepts `preApproved: true` and creates the payout already approved. The scope is restricted — it is granted only to admin-provisioned OAuth clients and to API keys for teams with the `payout.api_approve` permission. It is not available via dynamic client registration.

Pre-approved payouts are subject to balance enforcement: when the total of all open (approved but not yet paid out) payouts would exceed the team wallet balance, the request is rejected with HTTP 422 and error code `INSUFFICIENT_BALANCE` (the error `data` carries `walletBalance`, `totalOpenAmount`, and `currency`).

## Wallet and allowance [#wallet-and-allowance]

Approval and execution run against the team's wallet, a passkey wallet held by the team owner:

* A team must finish wallet setup before its payouts can be approved or executed. The hosted flows prompt the owner automatically if the wallet is missing.
* The owner can cap automated spending with a daily allowance; mint the hosted screen for it with `POST /session/allowance`.

## After approval [#after-approval]

Once approved and funded, Talentir requests the transfer with the payout method the recipient chose during claiming. Track progress via [webhooks](/docs/v1/guides/webhooks) (`approved` → `requested` → `completed`) or `GET /payout/{id}`. The full state machine is described in [Payout lifecycle](/docs/v1/concepts/payout-lifecycle).


# Create a payout (/docs/v1/guides/create-a-payout)



`POST /payout` creates (or updates) a payout for a recipient. The recipient does not need a Talentir account yet — they claim the payout later through a [hosted session](/docs/v1/guides/hosted-sessions).

## Identify the recipient [#identify-the-recipient]

Pick a `verificationMethod` that matches what you know about the recipient:

| `verificationMethod` | Recipient identified by   | Required fields                   |
| -------------------- | ------------------------- | --------------------------------- |
| `email`              | Email address             | `email`                           |
| `youtube-channel`    | YouTube handle            | `creatorHandle` (starts with `@`) |
| `tiktok`             | TikTok handle             | `creatorHandle` (starts with `@`) |
| `instagram`          | Instagram handle          | `creatorHandle` (starts with `@`) |
| `wallet_address`     | Talentir user ID / wallet | `walletAddress`                   |

The recipient proves control of that identity during the claim flow — for social handles by connecting the account, for email by signing in with a one-time code.

## Request [#request]

```bash
curl -X POST "$BASE_URL/payout" \
  -H "Authorization: Bearer $TALENTIR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "description": "Payment for YouTube channel campaign",
    "creatorHandle": "@mrbeast",
    "verificationMethod": "youtube-channel",
    "payoutAmount": "100.00",
    "currency": "EUR",
    "customId": "campaign-42"
  }'
```

Useful optional fields:

* `customId` — your identifier. Sending the same `customId` again updates the existing payout instead of creating a duplicate; the response's `action` field says `created` or `updated`. A deleted payout is final: the same `customId` then fails. See [Idempotency](/docs/v1/concepts/idempotency).
* `payoutType` — `manual` (default) or `manual-immutable` (source-of-truth fields cannot be updated after creation).
* `availableOn` — a UTC calendar date (`YYYY-MM-DD`) from which the payout can be claimed. Omit for immediately claimable.
* `tags` — free-form strings for categorizing payouts; filterable in `GET /payouts`.
* `notifications` — `allowed` (default) or `not-allowed` if you run your own notification system.
* `preApproved` — create the payout already approved. Requires the `payouts:approve` scope; see [Approve and execute payouts](/docs/v1/guides/approve-and-execute-payouts).

## Recipient invoices [#recipient-invoices]

By default Talentir issues a self-billing invoice in the recipient's name for every payout and returns it as `recipientInvoice`. With the `invoice_upload` feature enabled for your organization, recipients can upload their own invoice while claiming instead. Talentir reads the document and checks it against the payout: the amount and currency, the party it is addressed to, the issuer, and the VAT treatment. Self-billing stays available. `recipientInvoiceSource` on the payout tells you which document you got.

Currencies: `USD`, `EUR`, `CHF`, `GBP`. Amounts are decimal strings, minimum `0.1`.

For every parameter and the full response schema, see the generated **Payouts** reference pages.

## Delete a payout [#delete-a-payout]

A payout can be deleted as long as no recipient has requested it, i.e. while its status is `created`, `approved` or `expired`. Deleting emits a webhook event with status `deleted`.

```bash
curl -X DELETE "$BASE_URL/payout/{id}" \
  -H "Authorization: Bearer $TALENTIR_API_KEY"
```

Add `?id_type=custom_id` to delete by your own `customId`. A `requested` or `completed` payout cannot be deleted; the request fails with HTTP 409. Deleting an already deleted payout succeeds and returns it unchanged.

## Balance enforcement [#balance-enforcement]

For teams with balance enforcement enabled (the default), pre-approved payouts are rejected with HTTP 422 and error code `INSUFFICIENT_BALANCE` when the total of all open (approved but not yet paid out) payouts would exceed the team wallet balance. The error `data` contains `walletBalance`, `totalOpenAmount`, and `currency`.

## Sandbox test scenarios [#sandbox-test-scenarios]

In the sandbox, every payout method has a passing and a failing scenario. **Success is the default**: any regular recipient details settle the payout instantly and synchronously, including the webhook events a real payout would emit. To exercise the failure path, have the recipient claim with these magic values:

| Method                                                                                                            | Failure trigger                                                               |
| ----------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| SEPA (`bank-iban`), SWIFT (`bank-swift`), ACH (`bank-ach`), Fedwire (`bank-wire`), UK Faster Payments (`bank-uk`) | Account holder name containing **SANDBOX FAIL**                               |
| PayPal (`paypal`)                                                                                                 | Recipient email containing **sandbox-fail** (e.g. `sandbox-fail@example.com`) |
| Venmo (`venmo`)                                                                                                   | Recipient phone number ending in **0000**                                     |
| Crypto (`crypto`)                                                                                                 | Destination wallet address `0x000000000000000000000000000000000000dEaD`       |

A triggered failure behaves like a real provider rejection: the transfer is dispatched, then declined, and the payout never reaches `completed` (no `completed` webhook fires; the payout stays `requested`). For a bank, PayPal, or Venmo trigger, the sandbox then puts the payout on retry at once, with the code `account_invalid`, and the payout gets a `retry` object (see [Retries](/docs/v1/concepts/payout-lifecycle#retries)). In production, Talentir Operations reviews each return before the retry starts, so this step can take some time.


# Create hosted sessions (/docs/v1/guides/hosted-sessions)



The four `POST /session/*` endpoints mint stateless, unauthenticated Talentir URLs you send your users to — no iframe or UI work on your side. The endpoint validates the resource for the authenticated team (with OAuth this can be a customer's team that connected your app) and returns a `url`.

Sessions are **stateless**: nothing is stored. **Treat the returned `url` as opaque** — redirect the user to it as-is; do not build, parse, or depend on its shape. The URL itself confers no standing access — every screen still authenticates the visitor and gates the action — so a link is safe to email or hand to its intended recipient.

## Two audiences — send each link to the right person [#two-audiences--send-each-link-to-the-right-person]

| Endpoint             | Audience                                                | Purpose                              |
| -------------------- | ------------------------------------------------------- | ------------------------------------ |
| `/session/payout`    | The payout **recipient** (a creator, not a team member) | Sign up / sign in and claim a payout |
| `/session/approval`  | A **member** of the authenticated team                  | Review and approve pending payouts   |
| `/session/kyb`       | A team member                                           | Complete business verification       |
| `/session/allowance` | The team **owner**                                      | Set the daily spending allowance     |

**Team links** (`/session/approval`, `/session/kyb`, `/session/allowance`) are for members of the team the token authorizes. The screen requires the visitor to sign in *and* be a member of that team — anyone else gets a "request access" prompt. The sign-in code is pre-sent to the session creator; the optional `loginHint` field overrides who receives it.

**The recipient link** (`/session/payout`) is for the person being paid, who is **not** a member of your team. The screen shows the payout and lets the recipient sign in or sign up with their own email and claim it; no team membership is required.

Do not send a team link to a recipient or vice versa.

## Non-members can request access [#non-members-can-request-access]

It is safe to send a team-scoped session link to someone who is not (yet) a member of the team: after signing in they see a "Request access" screen instead of the flow. Requesting access emails the team's owner(s) with the requester's name and email and a one-click link to invite them from team settings — access is never granted automatically. Once invited and accepted, the same session link works.

## Wallet setup [#wallet-setup]

The team-member flows all operate on the team's wallet — a passkey wallet held by the **team owner**. If the team has not set one up yet, the hosted screen first prompts the owner to create it and then continues to the requested flow. Only the owner can complete this step; a non-owner member is asked to have the owner do it. A team must finish wallet setup before its payouts can be approved or executed. The recipient claim flow is unaffected — it uses the recipient's own wallet.

## Common parameters [#common-parameters]

| Parameter     | Required | Effect                                                                                                                                     |
| ------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| `redirectUrl` | yes      | Where the user is returned after completing or leaving the screen — also the target of the screen's "Go Back" control. Must be http/https. |
| `branding`    | no       | `talentir` (default), `team`, or `platform`. Selects the host and logo for the hosted flow.                                                |

Each endpoint adds its own input — `payoutId` (payout), `payoutIds` (approval), and optional `loginHint` values for the team-member flows. All require the `sessions:write` scope, which is granted by default.

## White-label branding [#white-label-branding]

| `branding`           | Hosted on                                                                                | Requires                                                                                                  |
| -------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `talentir` (default) | The main Talentir host                                                                   | —                                                                                                         |
| `team`               | The **authenticated team's** white-label subdomain (`{slug}.talentir.com`) with its logo | The team's white-label feature + configured subdomain                                                     |
| `platform`           | The subdomain and logo of the **team operating your OAuth client**                       | An OAuth-authenticated call with a dashboard-created client, and the operating team's white-label feature |

`platform` is the mode for platforms embedding Talentir-hosted flows in their own product: the session operates on the connected customer team, while the page chrome carries *your* brand. It is rejected for API-key calls (an API key has no operating platform). The deprecated `whitelabel` field on `/session/payout` is ignored — use `branding: "team"` instead. White-label features and subdomains are provisioned by Talentir as part of the partner program.

Sandbox and preview hosts do not provide per-team subdomains. On these hosts, Talentir keeps the session on the current host and carries the verified brand in a signed session token.

## Examples [#examples]

**Team link** — a member of the authenticated team approves payouts:

```bash
curl -X POST "$BASE_URL/session/approval" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "branding": "platform",
    "payoutIds": ["0e4ba886-0bfe-4b6a-ae2e-d6d9d135dd1e"],
    "redirectUrl": "https://yourapp.com/done"
  }'
```

**Recipient link** — the creator being paid claims their payout:

```bash
curl -X POST "$BASE_URL/session/payout" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "payoutId": "0e4ba886-0bfe-4b6a-ae2e-d6d9d135dd1e",
    "redirectUrl": "https://yourapp.com/done"
  }'
```


# Set up webhooks (/docs/v1/guides/webhooks)



Register a webhook to stay informed about payout status changes instead of polling.

## Manage subscriptions [#manage-subscriptions]

* `POST /webhook` — subscribe a URL to events.
* `GET /webhook` — list your subscriptions.
* `DELETE /webhook/{id}` — remove one.

Webhooks live in the environment they were created in: a webhook registered via the sandbox API only fires for sandbox events, and one registered via the production API only fires for production events.

## Event payload [#event-payload]

When events occur, webhooks receive HTTP POST requests with a JSON payload that matches the payout schema returned by `GET /payout/{id}`.

Optional fields are omitted when absent, including `paidOutAt` and `retry`.

## Event triggers [#event-triggers]

Webhooks fire for the payout lifecycle events:

* Payout creation (status `created`)
* Payout approval (status `approved`)
* Payout request (status `requested`)
* Payout return by the provider (status `requested`), and again when the recipient's retry opens (with the retry link in `retry.url`)
* Payout completion (status `completed`, only after delivery completes)
* Payout deletion (status `deleted`)
* Payout expiration (status `expired`)

A retry does not emit a completion notification. When the retry completes, the webhook uses the original public payout ID and the delivery details of the successful attempt.

## Delivery behavior [#delivery-behavior]

**Method**: POST, `Content-Type: application/json`.

**Request headers**:

* `X-Talentir-Signature`: HMAC-SHA256 signature for payload verification
* `X-Talentir-Timestamp`: Unix timestamp (seconds) when the webhook was sent

**Retry behavior**:

* **Server errors (5xx)**: retried up to 10 times with exponential backoff (10s, 20s, 40s, 80s, 160s, 320s, 640s, 1280s, 2560s, 5120s). Total retry window is approximately 2.8 hours.
* **Rate limiting (429)**: retried after a 60 second delay.
* **Gone (410)**: the webhook subscription is automatically removed. No retry.
* **Other client errors (4xx)**: not retried. These indicate a permanent failure (e.g. invalid endpoint configuration).
* **Success (2xx)**: delivery confirmed.

**Removal**: when 25 events in a row are not delivered after all retries, and these failures span at least 5 days, the webhook is removed, as it is for a 410. A removed webhook receives no events, and `GET /webhook` no longer lists it. A successful delivery resets the count. Every member of your team gets an email when we remove a webhook. To receive events again, fix the endpoint and create the webhook again.

## Verify signatures [#verify-signatures]

Every webhook request includes a cryptographic signature. **Always verify webhook signatures** to ensure requests are genuinely from Talentir.

1. Extract the signature and timestamp from the request headers.
2. Construct the signed payload: `{timestamp}.{raw_request_body}`.
3. Compute HMAC-SHA256 using your webhook's signing secret.
4. Compare the computed signature with `X-Talentir-Signature` using timing-safe comparison.
5. Optionally, reject requests with timestamps older than 5 minutes to prevent replay attacks.

```typescript
import { createHmac, timingSafeEqual } from 'crypto';

function verifyWebhookSignature(
  payload: string,
  signature: string,
  timestamp: string,
  signingSecret: string
): boolean {
  const signedPayload = `${timestamp}.${payload}`;
  const expectedSignature = createHmac('sha256', signingSecret)
    .update(signedPayload)
    .digest('hex');

  // Timing-safe comparison to prevent timing attacks
  const sigBuffer = Buffer.from(signature);
  const expectedBuffer = Buffer.from(expectedSignature);
  if (sigBuffer.length !== expectedBuffer.length) return false;

  return timingSafeEqual(sigBuffer, expectedBuffer);
}

// In your webhook handler:
app.post('/webhook', (req, res) => {
  const signature = req.headers['x-talentir-signature'];
  const timestamp = req.headers['x-talentir-timestamp'];
  const payload = JSON.stringify(req.body);

  // Reject requests older than 5 minutes
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - parseInt(timestamp)) > 300) {
    return res.status(400).send('Timestamp too old');
  }

  if (!verifyWebhookSignature(payload, signature, timestamp, YOUR_SIGNING_SECRET)) {
    return res.status(401).send('Invalid signature');
  }

  // Process the webhook...
});
```


# Team API keys (/docs/v2/authentication/api-keys)



All endpoints require Bearer token authentication. The simplest token is a team API key.

## Create a key [#create-a-key]

Go to **Dashboard → Integrations → API keys** and create a key. Store it in your secret manager; it is shown once.

## Use it [#use-it]

Send the key on every request:

```bash
curl "https://www.talentir.com/api/v2/team" \
  -H "Authorization: Bearer $TALENTIR_API_KEY"
```

API key access is scoped to the team that owns the key. To act on behalf of other teams (your customers), use [OAuth 2.1](/docs/v2/authentication/oauth) instead.

## Scopes [#scopes]

A new key carries every freely grantable scope: read and write payouts, counterparties, webhooks, and sessions, and read team information and transactions. Scopes are fixed when a key is created, so a key created before a scope existed must be edited (or replaced) to receive it. Keys created before v2 have no `counterparties:read` or `counterparties:write`.

The `payouts:approve` scope is restricted: it is only granted to keys of teams with the `payout.api_approve` permission. See [Scopes and permissions](/docs/v2/concepts/scopes-and-permissions).

## Good practice [#good-practice]

* Keys are environment-specific: a sandbox key does not work in production.
* Rotate keys from the dashboard; deleting a key revokes it immediately.
* Never embed keys in client-side code. Calls belong on your server.


# OAuth 2.1 with PKCE (/docs/v2/authentication/oauth)



For platform integrations, use the OAuth 2.1 authorization code flow with PKCE: you redirect your customer to Talentir's hosted authorization screens, they sign in (or sign up), select or create their team, and consent. Your server then exchanges the code for tokens scoped to that team.

## Get an OAuth client [#get-an-oauth-client]

**Create it in the dashboard (recommended):** go to **Settings → OAuth Clients** in your Talentir team dashboard, enter a name and your callback URL(s), and you receive a `client_id` and `client_secret` (shown once, with copy and download). Clients created there are *attributed to your team*, which unlocks the partner-program features: teams created through your authorize flow are credited to your platform, and hosted sessions can carry your white-label branding.

## Discovery [#discovery]

```
GET /.well-known/oauth-authorization-server/api/auth
```

This returns the `authorization_endpoint`, `token_endpoint`, `registration_endpoint`, and `jwks_uri`.

## Requirements [#requirements]

* `code_challenge` / `code_challenge_method=S256` — PKCE is mandatory.
* `resource=<base_url>/api/v2` on **both** `/authorize` and `/token`, so the token's `aud` claim binds to the API resource (for production: `https://www.talentir.com/api/v2`). v2 also accepts a token bound to `/api/v1`, so an existing integration keeps its tokens while it migrates.
* The user must select a team, or create a new one, during authorization; the chosen team is encoded in the `https://talentir.com/oauth/team_id` claim.
* Include `offline_access` in the scope list to receive a refresh token.

## Authorization hints [#authorization-hints]

Add any of these query parameters to the `/authorize` request to pre-fill the hosted screens when you already know who is connecting. They are *hints only* — the authenticated account and the selected team are always verified server-side.

| Parameter    | Value                                                                      | Effect                                                                                                                                                                                                       |
| ------------ | -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `login_hint` | Email address                                                              | Pre-fills the email and emails the one-time sign-in code automatically (social/passkey sign-in is hidden). If the user is already signed in with a different email, they are asked to switch accounts first. |
| `team_hint`  | The UUID part of a team `id` from `GET /team` (without the `team_` prefix) | Pre-selects that team on the team step. Ignored if the signed-in user is not a member of it.                                                                                                                 |
| `team_name`  | String                                                                     | Pre-fills the suggested name when the user creates or names a team on the team step.                                                                                                                         |

Example (URL-encode the values in practice):

```
https://www.talentir.com/api/auth/oauth2/authorize?response_type=code&client_id=<client_id>
  &redirect_uri=<redirect_uri>&scope=team:read payouts:write counterparties:write
  &code_challenge=<challenge>&code_challenge_method=S256
  &resource=https://www.talentir.com/api/v2
  &login_hint=creator@example.com
  &team_hint=4399dd07-cf1b-414b-bfbc-91a88dd73ec5
```

Teams created during your authorize flow are automatically attributed to your platform when your OAuth client was created in the dashboard. Partner-program enrollment (referral terms, white-label) is handled by Talentir — contact us to enroll.

## Scopes [#scopes]

See [Scopes and permissions](/docs/v2/concepts/scopes-and-permissions) for the full table.

## Try OAuth from the console [#try-oauth-from-the-console]

Open an endpoint in the API reference and select **Test** to use the Scalar API client. You need a `client_id` to run the OAuth flow. The quickest way for testing is a throwaway dynamically registered client:

```bash
curl -sX POST https://sandbox.talentir.com/api/auth/oauth2/register \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "Talentir API Docs",
    "redirect_uris": ["https://sandbox.talentir.com/api/v2"],
    "token_endpoint_auth_method": "none",
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"]
  }' | jq
```

Copy the `client_id` from the response, open the console, click **Authorize**, paste it, and run the flow. Docs-testing clients are dynamically registered, so they are anonymous — no attribution or platform branding.


# Errors and retries (/docs/v2/concepts/errors-and-retries)



## Error format [#error-format]

Every error has one shape: an `error` object with a machine-readable `code`, the HTTP `status`, a human-readable `message`, and, for some codes, a `details` object whose fields are fixed per code.

```json
{
  "error": {
    "code": "INSUFFICIENT_BALANCE",
    "message": "The team balance does not cover the approved payouts.",
    "status": 422,
    "details": {
      "total": "50.00",
      "required": "150.00",
      "currency": "EUR"
    }
  }
}
```

Branch on `code`, never on `message`.

## Common codes [#common-codes]

| Status | Code                                                                                                                                  | Meaning                                                                                                                                                                                                                                                                                                      |
| ------ | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 400    | `MALFORMED_REQUEST`                                                                                                                   | The body is not valid JSON                                                                                                                                                                                                                                                                                   |
| 400    | `VALIDATION_FAILED`                                                                                                                   | The request did not match the schema; `details.issues[]` lists each failing `path` and a `message`. `path` is an array of segments, and an array index is a number: `["payouts", 1, "amount"]`. Unknown fields are rejected, so a misspelt field surfaces here.                                              |
| 401    | `UNAUTHORIZED`                                                                                                                        | Missing or invalid token                                                                                                                                                                                                                                                                                     |
| 403    | `MISSING_SCOPE`                                                                                                                       | The token lacks the scope the endpoint needs                                                                                                                                                                                                                                                                 |
| 403    | `BRANDING_UNAVAILABLE`                                                                                                                | A session asks for `team` or `platform` branding without the white-label feature, or `platform` from an API key                                                                                                                                                                                              |
| 404    | `PAYOUT_NOT_FOUND`, `COUNTERPARTY_NOT_FOUND`, `WEBHOOK_NOT_FOUND`                                                                     | No such resource for the authenticated team                                                                                                                                                                                                                                                                  |
| 409    | `CUSTOM_ID_TAKEN`                                                                                                                     | Another resource holds this `customId`; `details.id` names it, or is `null` when a deleted payout holds it                                                                                                                                                                                                   |
| 409    | `PAYOUT_NOT_EDITABLE`, `PAYOUT_LOCKED`, `PAYOUT_NOT_DELETABLE`, `PAYOUT_NOT_APPROVABLE`, `PAYOUT_NOT_PAYABLE`, `PAYOUT_NOT_CLAIMABLE` | The payout's `status` (in `details`) does not allow the action                                                                                                                                                                                                                                               |
| 409    | `PAYOUT_NOT_YET_AVAILABLE`                                                                                                            | The payout cannot be claimed, sent, or paid before `availableOn`; `details.availableOn` says the day                                                                                                                                                                                                         |
| 409    | `KYB_ALREADY_SUBMITTED`                                                                                                               | A `kyb` session after the team submitted its application; check `kybStatus` first                                                                                                                                                                                                                            |
| 409    | `IDEMPOTENCY_KEY_REUSED`, `IDEMPOTENCY_KEY_IN_PROGRESS`                                                                               | See [Idempotency](/docs/v2/concepts/idempotency)                                                                                                                                                                                                                                                             |
| 422    | `INSUFFICIENT_BALANCE`                                                                                                                | Approval would exceed the team balance, or one payout is larger than every single payout currency; `details` carries `total` (the `balance.total` of the account; with several payout currencies, their sum in `currency`, or the largest one when a single payout is too large), `required`, and `currency` |
| 422    | `VERIFICATION_REQUIRED`                                                                                                               | The team is not ready to approve; `details.missing` lists every step left, with the same values as `missing` on `GET /team`                                                                                                                                                                                  |
| 422    | `ALLOWANCE_REQUIRED`                                                                                                                  | The team owner must set a daily spending allowance for automatic approvals                                                                                                                                                                                                                                   |
| 422    | `COUNTERPARTY_NOT_PAYABLE`                                                                                                            | The counterparty misses data a push payment needs; `details.missing` names the gaps                                                                                                                                                                                                                          |
| 422    | `RECIPIENT_NOT_COUNTERPARTY`                                                                                                          | Only a payout to a counterparty can be paid directly; send a claim link instead                                                                                                                                                                                                                              |
| 500    | `INTERNAL_ERROR`                                                                                                                      | Unexpected server fault                                                                                                                                                                                                                                                                                      |

Each endpoint's reference page lists the codes it can return.

## Retry guidance [#retry-guidance]

* **4xx** errors are yours to fix — retrying the identical request fails again (except 429).
* **5xx** errors and network timeouts are safe to retry with exponential backoff. Send the same `Idempotency-Key` on the retry, so a request that did succeed is replayed instead of repeated — see [Idempotency](/docs/v2/concepts/idempotency).
* A **replayed 500** (`Idempotent-Replayed: true`) does not change on another retry: the first attempt failed on our side and can have done part of its work. Check the state before you send the request again with a new key.
* Write clients that **ignore unknown fields**. New fields, enum values, and endpoints appear without notice; see [Changes without a new version](/docs/v2#changes-without-a-new-version).


# Idempotency (/docs/v2/concepts/idempotency)



Two mechanisms keep a retried request from doing its work twice. They do different jobs.

## `Idempotency-Key` [#idempotency-key]

Send an `Idempotency-Key` header on every `POST`. It is a client-chosen string of 1 to 255 characters; a UUID is recommended.

* The same key on the same endpoint for the same team within 24 hours replays the stored response, with the header `Idempotent-Replayed: true`. The retry does no work.
* The same key with a different body is 409 `IDEMPOTENCY_KEY_REUSED`.
* The same key while the first request is still running is 409 `IDEMPOTENCY_KEY_IN_PROGRESS`. Retry after a moment.
* A 500 `INTERNAL_ERROR` is stored like any other response, because the request can have done part of its work before the fault. Its replay does not run the request again. Check the state first, for a payout with `GET /payouts/{customId}`, then send the request with a new key if it is still needed.

So a timeout-and-retry loop is safe: send the identical request again and you get the original response back.

```bash
curl -X POST "$BASE_URL/payouts" \
  -H "Authorization: Bearer $TALENTIR_API_KEY" \
  -H "Idempotency-Key: payout-create:INV-42" \
  -H "Content-Type: application/json" \
  -d '{ "customId": "INV-42", "description": "Invoice 42", "amount": "100.00", "currency": "EUR", "recipient": { "type": "email", "email": "jane@example.com" } }'
```

## `customId` [#customid]

A `customId` is your own key for a payout or a counterparty: an order id, a campaign line, an invoice number. It is unique within your team and per resource.

A payout's `customId` is used once: it stays taken after the payout is deleted, so a repeated create can never pay twice. A deleted counterparty is gone from the API, and its `customId` is free again for a new counterparty.

* `POST /payouts` only creates. A second create with the same `customId` is 409 `CUSTOM_ID_TAKEN`, and `details.id` names the payout that holds it. `details.id` is `null` when a deleted payout holds it.
* A `customId` works in place of the id wherever a payout or a counterparty is referenced: the `{id}` in a path (`GET /payouts/campaign-42`), the `ids` of a batch request, `recipient.counterpartyId`, the `payoutId` and `payoutIds` of a session, and the `recipientCounterpartyId` filter. Responses always carry the prefixed id.
* A new or changed `customId` has 1 to 255 characters: ASCII letters, digits, `.`, `_`, `:`, and `-`, starting with a letter or a digit. Such a key is safe in a URL path as is. A key saved earlier through v1 that does not follow this format keeps working in every lookup and is returned as it is.
* A `customId` must not start with the resource prefix (`payout_`, `counterparty_`), so the two key spaces stay apart.

## Use both [#use-both]

The key says which request attempt this is, for 24 hours. The `customId` says which payout this is, forever. For a create, derive the key from your business key, for example `Idempotency-Key: payout-create:INV-42`. A retry inside 24 hours replays the 201. A retry later gets 409 `CUSTOM_ID_TAKEN` with the payout's id. Both end on the same payout.

## Locking a payout down [#locking-a-payout-down]

If the payout must not change after creation — for example when the request data comes from a signed source — create it with `"locked": true`. A locked payout refuses `PATCH` and dashboard edits with 409 `PAYOUT_LOCKED`. Only delete is left.

## What is not idempotent [#what-is-not-idempotent]

Session minting (`POST /sessions`) returns a fresh URL each call by design; the header still replays a retry. Webhook creation replays with the header too; without it, check `GET /webhooks` before subscribing if you might retry.


# Payout lifecycle (/docs/v2/concepts/payout-lifecycle)



Every payout moves through a small state machine. Each transition emits a [webhook event](/docs/v2/guides/webhooks) named after it, with the payout as `GET /payouts/{id}` returns it in `data`.

```
created ──approve──▶ approved ──send-claim-link, then the claim──▶ requested ──▶ completed
                        │      ──pay────────────────────────────▶      │
                        │                                              └──▶ failed ──pay──▶ requested
                        └──▶ expired
```

## States [#states]

| Status      | Meaning                                                                                                                                                                                              | Event              |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ |
| `created`   | The payout exists and waits for approval by the sending team.                                                                                                                                        | `payout.created`   |
| `approved`  | The sending team approved the payout. The amount is reserved against the team balance. The payout waits for the next step: a claim link and the recipient's claim, or `pay`. `approvedAt` says when. | `payout.approved`  |
| `requested` | The transfer was requested from the payment provider, by the recipient's claim or by `POST /payouts/{id}/pay`.                                                                                       | `payout.requested` |
| `completed` | The transfer settled. If the provider later returns it to a recipient with an account, the payout goes back to `requested` with a `retry` object.                                                    | `payout.completed` |
| `failed`    | The provider rejected the transfer. `failureCode` and `failureReason` say why. Fix the counterparty, then call `pay` again.                                                                          | `payout.failed`    |
| `expired`   | The payout expired unclaimed. Terminal. While a payout is `created` or `approved`, `expiresAt` says when this happens.                                                                               | `payout.expired`   |

The happy path is `created` → `approved` → `requested` → `completed`. `status` is an open enum: treat a value you do not know as a fallback case.

## Retrying a failed payout [#retrying-a-failed-payout]

`failed` is the status that asks the sender to act. A push payout to a counterparty you entered that the provider returns becomes `failed` as soon as Talentir Operations has reviewed the return. The payout keeps its approval, so no second approval is needed. Correct the counterparty with `PATCH /counterparties/{id}` and call `POST /payouts/{id}/pay` again; the payout returns to `requested`, and `payout.completed` or another `payout.failed` follows.

A claimed payout that the provider returns stays `requested`, and `completedAt` stays `null`. Talentir Operations reviews each return first; until the review ends, `retry` is `null`. After the review, Talentir emails the recipient a retry link to enter new payment details. Until a new attempt delivers the payout, `retry` gives `url`, `code`, and `reason`. `url` is the retry link: show it in your app while it is set, so the recipient finds it outside the email. It is `null` while the new attempt runs. `code` has the same values as `failureCode`, and `reason` is the same as text. After delivery, `retry` is `null` again.

After the retry completes, the payout becomes `completed` and emits `payout.completed`. `paymentMethod`, `requestedAt`, and `completedAt` use the successful delivery. The public payout ID and requested `amount` stay the same.

## Notes [#notes]

* `PATCH /payouts/{id}` is allowed only while the status is `created`. A `locked` payout refuses `PATCH` altogether.
* `DELETE /payouts/{id}` is allowed while the status is `created`, `approved`, or `expired`. A `requested`, `completed`, or `failed` payout cannot be deleted (409 `PAYOUT_NOT_DELETABLE`). The money of a `failed` payout waits for `pay`. A deleted payout is gone from the API: every endpoint answers 404 `PAYOUT_NOT_FOUND` for it, and lists do not include it. The `payout.deleted` event carries what `DELETE` returns: `{ object, id, deleted: true }`.
* `availableOn` delays the claim and the payment, not creation: the payout exists immediately but cannot be claimed or paid before that UTC day.
* A counterparty change (`PATCH /counterparties/{id}` on the address, payment method, or tax identity) moves every `approved` payout to that counterparty back to `created`, because the sender now pays a different destination. Each such payout emits `payout.updated`.
* From `requested` on, `paymentMethod` names the rail and settlement currency, and `recipientInvoiceSource` says who wrote the document behind `recipientInvoiceUrl`: `self_billing` (Talentir issued it in the recipient's name) or `recipient_upload` (the recipient uploaded their own invoice while claiming).


# Scopes and permissions (/docs/v2/concepts/scopes-and-permissions)



Every token — team API key or OAuth access token — carries scopes that gate what it can do. An endpoint that is called without its scope fails with 403 `MISSING_SCOPE`.

## Scopes [#scopes]

| Scope                  | Grants                                                                                                                                                                                                                                                                                         |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `team:read`            | Read the team, its accounts (balances and deposit instructions), and members                                                                                                                                                                                                                   |
| `transactions:read`    | List and read transactions                                                                                                                                                                                                                                                                     |
| `payouts:read`         | List and read payouts                                                                                                                                                                                                                                                                          |
| `payouts:write`        | Create, update, and delete payouts; send claim links; pay approved payouts to a counterparty                                                                                                                                                                                                   |
| `payouts:approve`      | Approve payouts, on `POST /payouts/{id}/approve` or with `andThen` on create; pay a `failed` payout again. **Restricted** — only granted to admin-provisioned OAuth clients and to API keys for teams with the `payout.api_approve` permission. Not available via dynamic client registration. |
| `counterparties:read`  | List and read counterparties, countries, and payment method types                                                                                                                                                                                                                              |
| `counterparties:write` | Create, update, and delete counterparties; read countries and payment method types                                                                                                                                                                                                             |
| `webhooks:read`        | List webhooks and their deliveries                                                                                                                                                                                                                                                             |
| `webhooks:write`       | Create, update, delete, and test webhooks; rotate their secrets. An event carries its resource in full, so subscribing to payout events also needs `payouts:read`, to counterparty events `counterparties:read`, and to transaction events `transactions:read`.                                |
| `sessions:write`       | Mint hosted session URLs (granted by default)                                                                                                                                                                                                                                                  |

`POST /team/sandbox-deposit` needs no scope. It adds test money and works only in the sandbox.

For OAuth, include `offline_access` in the scope list to receive a refresh token.

## Which team does a token act on? [#which-team-does-a-token-act-on]

* A **team API key** is scoped to the team that owns the key.
* An **OAuth access token** is scoped to the team the user selected during authorization, carried in the `https://talentir.com/oauth/team_id` claim. For platforms this is usually a customer's team, not your own.
* An OAuth token's effective scopes shrink to what the authorizing member's current role allows. A member's token never carries `payouts:approve`, and a demoted member's token loses it on its next request.

## Safety model [#safety-model]

`payouts:write` alone can never move money: a created payout stays `created` until a team member approves it (see [Approve payouts](/docs/v2/guides/approve-payouts)). Approval reserves the funds and always requires either the restricted `payouts:approve` scope or an explicit human approval backed by the team's passkey wallet and daily allowance.

Paying an approved payout to a counterparty (`POST /payouts/{id}/pay`) needs only `payouts:write`, because it moves what approval already reserved, to the destination approval already covered.

Paying a `failed` payout again also needs `payouts:approve`. The money goes to the counterparty's current payment method, which can have changed since approval.


# Transactions (/docs/v2/concepts/transactions)



A transaction is one movement of money on one of your team's accounts. It has one `currency`: the account it belongs to. List transactions with [`GET /transactions`](/docs/v2/reference/transactions/transactions.list) and read one with [`GET /transactions/{id}`](/docs/v2/reference/transactions/transactions.get). Both need the `transactions:read` scope.

Transaction IDs have the form `transaction_<uuid>_in` or `transaction_<uuid>_out`. The suffix identifies the side of the movement.

## Types [#types]

| Type       | Meaning                                                                                                                            |
| ---------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `deposit`  | A bank transfer into the account.                                                                                                  |
| `payout`   | A payout the team sent.                                                                                                            |
| `payment`  | A payout the team received on the account. A payout paid to a bank account or PayPal is not an account movement and is not listed. |
| `fee`      | A Talentir fee, charged with the payout in `payoutIds` or as a monthly plan fee.                                                   |
| `transfer` | An on-chain transfer that no payout or deposit explains.                                                                           |

## Statuses [#statuses]

| Status      | Meaning                                                                                                                                                                                                                                                         |
| ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pending`   | The on-chain transfer is not indexed yet. A deposit is `pending` from the bank arrival until Talentir has sent the stablecoin; a payout from the request until the money leaves the account.                                                                    |
| `completed` | The on-chain transfer is indexed and the balance has moved. `completedAt` is when it was mined. A payout on a bank or PayPal rail is `completed` when the money leaves the account, before the bank confirms delivery; `payout.completed` reports the delivery. |
| `failed`    | The money will never move: a cancelled payout, or a rejected or refunded deposit.                                                                                                                                                                               |

`type` and `status` are open enums: treat a value you do not know as a fallback case.

## Balance [#balance]

`amount` is unsigned. `direction` gives the sign: `in` adds `amount` to the account, `out` takes it away. The sum of all `completed` transactions of a currency, signed by `direction`, equals `balance.total` of that account in [`GET /team/accounts`](/docs/v2/reference/team/team.listAccounts). `pending` and `failed` transactions do not count.

A transfer between two of your team's wallets has an outgoing transaction and an incoming transaction. Both have the same `txHash`, but distinct IDs. Their amounts cancel out, so the transfer does not change the account balance. Each side sends its own `transaction.created` event.

## Payouts [#payouts]

`payoutIds` lists the payouts of your team that a transaction settles or charges for.

* A `payout` transaction lists the payouts it settles. One transfer can settle several payouts, so the list can have more than one entry.
* A `fee` transaction lists the payouts it was charged for. A monthly plan fee has an empty list.
* `deposit`, `payment`, and `transfer` transactions have an empty list.

To find every transaction of one payout, filter `GET /transactions` by `payoutId`.

`counterpartyId` is the counterparty those payouts go to. It is `null` when the transaction is not tied to a payout, and when one transfer paid payouts to several counterparties.

`txHash` is the hash of the on-chain transaction. It is `null` while the money has not moved on-chain.

## Deposits [#deposits]

A `deposit` carries where the money came from. `account` is the sender's bank account in the same shape as a counterparty's payment method, plus the account `holder` as the bank reported it. The variant is the one a counterparty with that account would be saved in:

| Sender                                                                              | `account.type`                                                                               |
| ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| A US ACH deposit                                                                    | `bank_ach` with the sender bank's `routingNumber`; US banks do not report the account number |
| A US domestic wire                                                                  | `bank_wire` with the sender bank's `routingNumber`                                           |
| An international wire into the US account                                           | `bank_swift` with `accountNumber`, `bic`, and `bankCountry`                                  |
| A UK account paying GBP, reported as a GB IBAN                                      | `bank_uk` with `sortCode` and `accountNumber`                                                |
| Any other IBAN: SEPA, CHF, a UK account paying EUR, a GBP sender with a non-UK IBAN | `bank_iban`                                                                                  |
| Anything else, for example a hand-recorded deposit                                  | `bank_swift` with `bankCountry` when known                                                   |

`reference` is the payment reference on the transfer. Both fields are `null` on every other type.

## Documents [#documents]

`documents` lists the PDFs that account for the transaction, each with a `type`, the `number` printed on it, a download `url`, and the `issuedAt` date.

* A `payout` or `payment` carries the recipient's invoice once it exists: a `self_billing_invoice` that Talentir issued in the recipient's name, or a `recipient_invoice` the recipient uploaded.
* A `fee` carries Talentir's `fee_invoice`.
* A `deposit` or `transfer` has no documents.

The list is empty until the document is issued, which can be after the transaction is `completed`. The payout endpoints return the same documents as `senderInvoiceUrl` and `recipientInvoiceUrl`.

## Event [#event]

`transaction.created` fires once per transaction, when the on-chain transfer behind it is final. The indexer confirms a transfer a few minutes after it is mined, so the event follows the balance change, not the payout's status change. `data` is the transaction as `GET /transactions/{id}` returns it, with `status` `completed`. A `pending` or `failed` transaction sends nothing; listen to `payout.failed` for a failed payout.

Subscribe with `transaction.*` or `transaction.created`. The webhook needs `transactions:read`. See [Set up webhooks](/docs/v2/guides/webhooks).


# Approve payouts (/docs/v2/guides/approve-payouts)



A payout created through the API starts in status `created`: it exists, but no money moves until the sending team approves it. Approval reserves the amount against the team balance. This split keeps the API safe by default: `payouts:write` can never move funds on its own.

After approval, sending the payout is a separate, explicit step. Send a [claim link](/docs/v2/guides/pay-with-payout-link#3-send-the-claim-link) or [pay a counterparty](/docs/v2/guides/pay-directly#3-create-the-payout-and-pay-it).

## Who can approve [#who-can-approve]

| Credential                                   | Approval through the API                                                                       |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| Your own team's API key                      | Off by default. Talentir enables the `payout.api_approve` permission for your team on request. |
| An OAuth client acting for a customer's team | Typically not enabled. The customer approves on a hosted screen.                               |

Without the permission, approval is a human step: a team member approves in the dashboard or on a hosted approval screen. &#x2A;*For platforms, the hosted approval screen is the recommended way.** It keeps the decision to move money with your customer, so your platform never controls their funds and stays clear of the regulated activity of a licensed payment provider.

## Option 1: hosted approval screen [#option-1-hosted-approval-screen]

Mint a screen for a member of the team with `POST /sessions`, `type: "payout_approval"`, and a list of `payoutIds`. The member reviews and approves the batch on a Talentir-hosted page, then returns to your `redirectUrl`.

```bash
curl -X POST "$BASE_URL/sessions" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "payout_approval",
    "payoutIds": ["payout_0e4ba886-0bfe-4b6a-ae2e-d6d9d135dd1e"],
    "redirectUrl": "https://yourapp.com/done"
  }'
```

Your product shows "payouts ready", the customer clicks through, reviews, and approves. The screen requires the visitor to sign in and be a member of that team. Add `branding: "platform"` to show your own brand. See [Pay on behalf of your customers](/docs/v2/guides/platform-integrations#hosted-sessions) for every session field.

## Option 2: the dashboard [#option-2-the-dashboard]

Team members with approval permission see `created` payouts under **Payouts** in the Talentir dashboard and approve them there. No integration work needed.

## Option 3: the API [#option-3-the-api]

With the `payouts:approve` scope, call `POST /payouts/{id}/approve`, or create the payout with `"andThen": "approve"` to create and approve in one request.

```bash
curl -X POST "$BASE_URL/payouts/campaign-42/approve" \
  -H "Authorization: Bearer $TALENTIR_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "andThen": "send_claim_link" }'
```

The body is optional. `andThen` runs the next step in the same request: `send_claim_link` or `pay` (`pay` also needs `payouts:write` and a counterparty recipient). The request either does both steps or neither. The claim link email goes out only after the approval is saved. If it cannot be sent, the payout stays `approved` with `claimLinkSentAt: null`; call `send-claim-link` again. The `{id}` in the path is the `payout_…` id or your `customId`.

Automatic approval spends from the team's wallet without a human in the loop, so the team owner must first set a daily spending allowance. See [Wallet and allowance](#wallet-and-allowance).

## Errors [#errors]

| Code                    | Meaning                                                                                                                                                                                     |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PAYOUT_NOT_APPROVABLE` | The payout is not in status `created`.                                                                                                                                                      |
| `VERIFICATION_REQUIRED` | The team is not ready to approve. `details.missing` names what is left, as in the `missing` of `GET /team`. For `kyb`, mint a `kyb` session.                                                |
| `INSUFFICIENT_BALANCE`  | The approved payouts would exceed the team balance; `details` carries `total`, `required`, and `currency`. Deposit first: `GET /team/accounts` lists the deposit instructions per currency. |
| `ALLOWANCE_REQUIRED`    | Automatic approval needs a daily spending allowance set by the owner. Mint an `allowance` session.                                                                                          |

## Wallet and allowance [#wallet-and-allowance]

Approval and execution run against the team's wallet, a passkey wallet held by the team owner:

* A team must finish wallet setup before its payouts can be approved or executed. The hosted flows prompt the owner automatically if the wallet is missing.
* The owner can cap automated spending with a daily allowance; mint the hosted screen for it with `POST /sessions` and `type: "allowance"`. In the same screen, the owner can add more payout currencies.
* `GET /team/accounts` shows the allowance of each account as `allowance`: the daily `limit`, what was `used` since the last reset, and `resetsAt`. It is `null` while the owner has not set one. Every approval needs the allowance of the account in the team's `defaultPayoutCurrency` from `GET /team`. Another account with `payoutsEnabled` pays only while it has an allowance, but it never blocks an approval.

## Batches [#batches]

A payout run, such as a monthly creator payout, uses the batch forms of the same four calls. Each takes up to 100 items and answers with a list in request order.

| Endpoint                        | Body                                                                           |
| ------------------------------- | ------------------------------------------------------------------------------ |
| `POST /payouts/batch`           | `{ "payouts": [ ... ] }`, each item a `POST /payouts` body, `andThen` included |
| `POST /payouts/approve`         | `{ "ids": [ ... ], "andThen": "send_claim_link" }`                             |
| `POST /payouts/send-claim-link` | `{ "ids": [ ... ] }`                                                           |
| `POST /payouts/pay`             | `{ "ids": [ ... ] }`                                                           |

```bash
curl -X POST "$BASE_URL/payouts/approve" \
  -H "Authorization: Bearer $TALENTIR_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "ids": ["campaign-42", "campaign-43"], "andThen": "pay" }'
```

* **All or nothing.** One failing item fails the whole request with that item's own code, and nothing is applied. The error carries `index`, the 0-based position of the item. Errors about the team, such as `INSUFFICIENT_BALANCE` or `VERIFICATION_REQUIRED`, have no `index`.
* **One balance check.** A batch approve reserves the sum of all payouts in one decision.
* **`ids` take payout ids or `customId`s**, each once. Pass explicit ids, not a filter: a payout created between your list call and your approve call must not slip in.
* **No batch object.** There is no batch status and no batch event. Give the payouts of one run a `tag` to find them again with `GET /payouts?tag=`.
* **Settlement is per payout.** A batch `pay` moves every payout to `requested` at once; `payout.completed` and `payout.failed` then arrive one by one.

## After approval [#after-approval]

Send the payout with a claim link or a push to a counterparty. Track progress via [webhooks](/docs/v2/guides/webhooks) (`payout.approved` → `payout.requested` → `payout.completed`) or `GET /payouts/{id}`. A provider rejection surfaces as `payout.failed` with a `failureReason`; the payout keeps its approval and is paid again with `pay`, which then also needs `payouts:approve`. The full state machine is described in [Payout lifecycle](/docs/v2/concepts/payout-lifecycle).


# Migrate from v1 to v2 (/docs/v2/guides/migrate-to-v2)



API v2 lives at `/api/v2`. It runs next to v1, and a v1 integration keeps working until v1's sunset date. That date is announced at least 90 days ahead.

Payouts, counterparties, and the team are shared. A payout you created through v1 is readable and manageable through v2, and the other way round. The JSON shape differs, and so does some behavior; this guide lists both. Webhooks are not shared: v1 and v2 each see only their own webhooks.

Two v1 rules changed together with v2. A `customId` is unique per team, not across all teams. And a deleted payout is final: v1 `POST /payout` with its `customId` or its `id` fails instead of bringing the payout back. Its `customId` stays taken.

## Base URL and authentication [#base-url-and-authentication]

| Environment | v1                                    | v2                                    |
| ----------- | ------------------------------------- | ------------------------------------- |
| Production  | `https://www.talentir.com/api/v1`     | `https://www.talentir.com/api/v2`     |
| Sandbox     | `https://sandbox.talentir.com/api/v1` | `https://sandbox.talentir.com/api/v2` |

Authentication does not change. Send your API key or OAuth access token as `Authorization: Bearer <token>`. The OpenAPI document is at `/api/v2/spec.json`.

v2 accepts an OAuth token that is bound to `/api/v1`. v1 does not accept a token that is bound to `/api/v2`. While your integration still calls v1, request tokens with `resource=<base_url>/api/v1`, also when the user authorizes again for the new scopes. Both versions accept these tokens. See [OAuth](/docs/v2/authentication/oauth).

Test the migration in the sandbox first. It has its own teams, API keys, and webhooks.

## What v2 adds [#what-v2-adds]

* **Push payouts.** Pay a counterparty's saved bank account, PayPal or Venmo account, or crypto wallet directly, without a claim link: `POST /payouts/{id}/pay`.
* **Counterparties as a resource.** Create, read, update, delete, and list the people and companies you pay: `/counterparties`.
* **A country reference.** `GET /countries` returns, for every country Talentir pays to, the tax fields a counterparty from that country needs, split into `individual` and `organization`, with the combinations your team must collect. New tax rules appear there, not in a new API version.
* **A payment method reference.** `GET /payment-method-types` returns every rail a counterparty can save, with the currencies a fiat rail settles in and the tokens `crypto` pays out. New currencies and assets appear there, not in a new API version.
* **An explicit payout cycle.** Create, approve, then either `send-claim-link` or `pay`. Talentir emails the claim link only after you call `send-claim-link`.
* **Batch endpoints.** Create, approve, send claim links for, or pay 1 to 100 payouts in one request.
* **Cursor pagination, one error shape, idempotency keys, prefixed ids, typed webhook events.** See [Conventions in short](#conventions-in-short).

## What v2 removes [#what-v2-removes]

* Creating payouts to a TikTok, Instagram, or YouTube handle. v2 creates payouts to an **email** or to a **counterparty** only. v2 still returns the handle payouts that v1 created. Their recipient cannot change; their other fields can.
* Creating payouts to a wallet address. To pay a wallet, save it as a counterparty's `crypto` payment method and [pay directly](/docs/v2/guides/pay-directly#pay-to-a-crypto-wallet). A recipient who claims can still choose crypto.
* Upsert on `POST /payout`. v2 `POST /payouts` only creates. Updates are `PATCH /payouts/{id}`.
* `id_type=custom_id`. The `{id}` in a payout or counterparty path accepts either the prefixed id or your `customId`.
* `offset`, `order_by`, and `order_direction` on `GET /payouts`. v2 lists page with a cursor and are always newest first.
* Deprecated v1 fields: `uuid`, `url`, `whitelabel`, `teamUuid`, `depositInfo`.

v2 ignores a query parameter it does not know. It does not reject it. A loop that pages with `offset` gets the first page every time, and `id_type` has no effect.

## Ids [#ids]

Resource ids in v2 carry a prefix that names the resource:

| v1                                              | v2                                            |
| ----------------------------------------------- | --------------------------------------------- |
| `0e4ba886-0bfe-4b6a-ae2e-d6d9d135dd1e` (payout) | `payout_0e4ba886-0bfe-4b6a-ae2e-d6d9d135dd1e` |
| `7c1d2f30-…` (`counterpartyId`)                 | `counterparty_7c1d2f30-…`                     |
| `…` (team `id`)                                 | `team_…`                                      |

The part after the prefix is the same value v1 returns. To reuse the payout, counterparty, and team ids you stored from v1, add the prefix. The UUID must be lowercase. A v1 webhook id does not work in v2; see [Webhooks](#webhooks). The OAuth `team_hint` takes the UUID without the prefix.

New resources use the prefixes `webhook_`, `delivery_`, `event_`, and `session_`. A country id is its ISO code (`DE`). A payment method type id is its rail name (`bank_iban`). Team members and accounts have no id.

v2 accepts only the prefixed form. Wherever a payout or counterparty is referenced, a value without the prefix is read as a `customId`. A bare v1 UUID there finds nothing: a path or a request body returns 404, and the `recipientCounterpartyId` filter returns an empty list. In a webhook path, a bare UUID returns 400 `VALIDATION_FAILED`. A value with the prefix but no valid UUID after it, for example `payout_123`, also returns 400 `VALIDATION_FAILED`.

A `customId`:

* has 1 to 255 characters when you set or change it: ASCII letters, digits, `.`, `_`, `:`, and `-`, starting with a letter or a digit. Such a key is safe in a URL path as is. A key saved through v1 that does not follow this rule is returned as it is and works in lookups, with two exceptions: an empty key, and a key that starts with the resource's prefix, such as `payout_…`. Look these payouts up by their id.
* must not start with the resource's prefix. `customId: "payout_…"` is rejected with 400 `VALIDATION_FAILED`.
* is unique within your team and per resource. Payouts and counterparties have separate spaces.
* is refused on create and on `PATCH` while another payout or counterparty holds it: 409 `CUSTOM_ID_TAKEN`, and `details.id` names the holder.
* stays taken after its payout is deleted, so a repeated create can never pay twice. `details.id` is then `null`. To redo a deleted payout, use a new `customId`.
* is free again after its counterparty is deleted.
* works wherever a payout or counterparty is referenced: the `{id}` in a path, the `ids` of a batch request, `recipient.counterpartyId`, the `payoutId` and `payoutIds` of a session, and the `recipientCounterpartyId` filter. Responses always carry the prefixed id.

A deleted payout or counterparty is gone from the API: every endpoint answers 404 `PAYOUT_NOT_FOUND` or `COUNTERPARTY_NOT_FOUND` for it, and lists do not include it. v1 `GET /payout/{id}` still returns a deleted payout with status `deleted`. Payouts to a deleted counterparty keep its id in `recipient.counterpartyId`.

In v1, a `POST /payout` with a known `customId` updated that payout. v2 never does.

## Requests [#requests]

* A request body with an unknown field fails with 400 `VALIDATION_FAILED`. Remove every v1-only top-level field before you send a v2 request: `payoutAmount`, `verificationMethod`, `email`, `phone`, `counterpartyId`, `creatorHandle`, `handleType`, `walletAddress`, `payoutType`, `notifications`, `preApproved`, `id`, `whitelabel`, and, for webhooks, `targetUrl` and `eventType`.
* `amount` is a decimal string with up to two decimals and a minimum of `0.10`, for example `"100.00"`.
* `description` has 1 to 5000 characters. v1 had no maximum.
* `tags` is an array of at most 20 tags with 1 to 100 characters each, or you do not send it. `null` is rejected.
* An email has at most 254 characters. `recipient.phone` is E.164 with no spaces, for example `+4915112345678`.
* A calendar date such as `availableOn` is `YYYY-MM-DD` and must be a real day. `2026-02-30` is rejected with 400 `VALIDATION_FAILED`.
* A `redirectUrl` must be `https` (`http` only for `localhost`) and has at most 2048 characters. v1 accepted `http`.
* Every create returns 201: payouts, payout batches, counterparties, webhooks, and sessions. v1 returned 200.
* Send `Idempotency-Key` on every `POST`. See [Idempotency](#idempotency).

## Endpoint map [#endpoint-map]

| v1                                   | v2                                                                                                   | Notes                                                                                                                                                                                                                                                                                                   |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /payout`                       | `POST /payouts`                                                                                      | Create only. `preApproved` is `andThen: "approve"`.                                                                                                                                                                                                                                                     |
| `POST /payout` with `id`             | `PATCH /payouts/{id}`                                                                                | Only while `status` is `created`; any other status fails with 409 `PAYOUT_NOT_EDITABLE`. A locked `created` payout fails with 409 `PAYOUT_LOCKED`. v1 replaced every field and also updated approved and expired payouts. v2 changes only the fields you send, and cannot bring an expired payout back. |
| `GET /payout/{id}`                   | `GET /payouts/{id}`                                                                                  | `{id}` is `payout_…` or your `customId`.                                                                                                                                                                                                                                                                |
| `GET /payout/{id}?id_type=custom_id` | `GET /payouts/{customId}`                                                                            | No query parameter.                                                                                                                                                                                                                                                                                     |
| `GET /payouts`                       | `GET /payouts`                                                                                       | See [Lists](#lists).                                                                                                                                                                                                                                                                                    |
| `DELETE /payout/{id}`                | `DELETE /payouts/{id}`                                                                               | v1 returned the payout. v2 returns `{ object, id, deleted: true }`, and the payout is gone from v2. Deleting it again returns 404; v1 returned 200.                                                                                                                                                     |
| —                                    | `POST /payouts/{id}/approve`                                                                         | In v1: `preApproved`, the dashboard, or an approval session.                                                                                                                                                                                                                                            |
| —                                    | `POST /payouts/{id}/send-claim-link`                                                                 | New. Talentir emails the claim link.                                                                                                                                                                                                                                                                    |
| —                                    | `POST /payouts/{id}/pay`                                                                             | New. Push payout to a counterparty.                                                                                                                                                                                                                                                                     |
| —                                    | `POST /payouts/batch`, `POST /payouts/approve`, `POST /payouts/send-claim-link`, `POST /payouts/pay` | New. The same steps for 1 to 100 payouts. All or nothing: one failing item fails the request. See [Errors](#errors) for `index`.                                                                                                                                                                        |
| —                                    | `/counterparties`, `GET /countries`, `GET /payment-method-types`                                     | New.                                                                                                                                                                                                                                                                                                    |
| `POST /session/payout`               | `POST /sessions` with `type: "payout_claim"`                                                         | See [Sessions](#sessions).                                                                                                                                                                                                                                                                              |
| `POST /session/approval`             | `POST /sessions` with `type: "payout_approval"`                                                      |                                                                                                                                                                                                                                                                                                         |
| `POST /session/kyb`                  | `POST /sessions` with `type: "kyb"`                                                                  |                                                                                                                                                                                                                                                                                                         |
| `POST /session/allowance`            | `POST /sessions` with `type: "allowance"`                                                            |                                                                                                                                                                                                                                                                                                         |
| `GET /team`                          | `GET /team`, `GET /team/members`, `GET /team/accounts`                                               | See [Team](#team).                                                                                                                                                                                                                                                                                      |
| `GET /webhook`                       | `GET /webhooks`                                                                                      | Lists v2 webhooks only.                                                                                                                                                                                                                                                                                 |
| `POST /webhook`                      | `POST /webhooks`                                                                                     | See [Webhooks](#webhooks).                                                                                                                                                                                                                                                                              |
| `DELETE /webhook/{id}`               | `DELETE /webhooks/{id}`                                                                              | Deletes v2 webhooks only. Delete a v1 webhook through v1.                                                                                                                                                                                                                                               |
| —                                    | `GET /webhooks/{id}`, `PATCH /webhooks/{id}`, `rotate-secret`, `test`, `deliveries`                  | New.                                                                                                                                                                                                                                                                                                    |

## Payout field map [#payout-field-map]

| v1                                                                                    | v2                                                                                    | Notes                                                                                                                                                                                                      |
| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`, `uuid`                                                                          | `id`                                                                                  | Prefixed. `uuid` is gone.                                                                                                                                                                                  |
| —                                                                                     | `object`                                                                              | Always `"payout"`.                                                                                                                                                                                         |
| `payoutAmount` (request), `amount` (response)                                         | `amount`                                                                              | Same name everywhere. Decimal string. Responses always have two decimals; an amount that v1 stored with more decimals is rounded.                                                                          |
| `currency`                                                                            | `currency`                                                                            |                                                                                                                                                                                                            |
| `description`                                                                         | `description`                                                                         |                                                                                                                                                                                                            |
| `customId`                                                                            | `customId`                                                                            |                                                                                                                                                                                                            |
| `verificationMethod: "email"`, `email`, `phone` (request), `phoneNumber` (response)   | `recipient: { type: "email", email, phone }`                                          | One object, same names in request and response. `phone` is `null` when not set.                                                                                                                            |
| `verificationMethod: "legal-entity"`, `counterpartyId`                                | `recipient: { type: "counterparty", counterpartyId }`                                 | Prefixed id. No `email` or `phone` in this variant: the claim link goes to the counterparty's `email`.                                                                                                     |
| `verificationMethod` of `tiktok`, `instagram`, `youtube-channel`, and `creatorHandle` | `recipient: { type, handle }` with `type` of `tiktok`, `instagram`, `youtube_channel` | v2 cannot create these or change their recipient.                                                                                                                                                          |
| `verificationMethod: "wallet_address"`, `walletAddress`                               | `recipient: { type: "wallet", walletAddress }`                                        | v2 cannot create these or change their recipient.                                                                                                                                                          |
| `handleType`                                                                          | `recipient.type`                                                                      | `handleType: "none"` is `type: "email"`.                                                                                                                                                                   |
| `email`, `phoneNumber` on a counterparty, handle, or wallet payout                    | —                                                                                     | Not in the payout. `send-claim-link` still uses the email and phone that v1 stored. Read a counterparty's `email` with `GET /counterparties/{id}`.                                                         |
| `payoutType: "manual"`                                                                | `locked: false`                                                                       | The default.                                                                                                                                                                                               |
| `payoutType: "manual-immutable"`                                                      | `locked: true`                                                                        |                                                                                                                                                                                                            |
| `payoutType` of `advance`, `affiliate`, `referral_reward` (response only)             | `locked: false`                                                                       | v2 does not show the type. A v2 `PATCH` of such a payout makes it `manual`.                                                                                                                                |
| `notifications: "allowed"`                                                            | `recipientNotifications: "enabled"`                                                   | The default. `claimLinkSentAt` shows when Talentir last emailed the claim link.                                                                                                                            |
| `notifications: "not-allowed"`                                                        | `recipientNotifications: "disabled"`                                                  | Talentir sends the recipient nothing, and the dashboard cannot send the claim link. See [The payout cycle](#the-payout-cycle).                                                                             |
| `preApproved: true`                                                                   | `andThen: "approve"`                                                                  | Same status, `approved`, and no email. Needs `payouts:approve`. The error codes change; see [Errors](#errors). The dashboard does not send a claim link for it; see [The payout cycle](#the-payout-cycle). |
| —                                                                                     | `andThen: "approve_and_send_claim_link"`, `andThen: "approve_and_pay"`                | New. Runs the later steps in the same request.                                                                                                                                                             |
| `availableOn`                                                                         | `availableOn`                                                                         |                                                                                                                                                                                                            |
| `tags`                                                                                | `tags`                                                                                | Never `null`.                                                                                                                                                                                              |
| `status`                                                                              | `status`                                                                              | See [Status values](#status-values).                                                                                                                                                                       |
| `requestedAt`                                                                         | `requestedAt`                                                                         | Also set by `pay`.                                                                                                                                                                                         |
| `paidOutAt`                                                                           | `completedAt`                                                                         | Set only while `status` is `completed`.                                                                                                                                                                    |
| `toMethod`                                                                            | `paymentMethod.type`                                                                  | See [Payment method values](#payment-method-values). `paymentMethod` is `null` until `requested`. After `pay` on a `failed` payout, v2 shows the new transfer and v1 the first one.                        |
| `toAsset`                                                                             | `paymentMethod.asset`                                                                 | The same CAIP-19 asset id. Present only when `type` is `crypto`.                                                                                                                                           |
| —                                                                                     | `paymentMethod.currency`                                                              | New. The currency the money settles in. It can differ from `currency`.                                                                                                                                     |
| `senderInvoice`, `recipientInvoice`                                                   | `senderInvoiceUrl`, `recipientInvoiceUrl`                                             |                                                                                                                                                                                                            |
| `recipientInvoiceSource`                                                              | `recipientInvoiceSource`                                                              | `self-billing` becomes `self_billing`. `recipient-upload` becomes `recipient_upload`.                                                                                                                      |
| `createdAt`, `updatedAt`                                                              | `createdAt`, `updatedAt`                                                              |                                                                                                                                                                                                            |
| `url`                                                                                 | —                                                                                     | Mint one with `POST /sessions`.                                                                                                                                                                            |
| `action` (create response)                                                            | —                                                                                     | A create is always a create. Status 201.                                                                                                                                                                   |
| —                                                                                     | `failureCode`, `failureReason`, `approvedAt`, `claimLinkSentAt`, `expiresAt`          | New.                                                                                                                                                                                                       |

Every top-level field is present in every response. A field with no value is `null`, never omitted. v1 omitted empty fields. The keys inside `recipient` and `paymentMethod` depend on their `type`.

### Status values [#status-values]

| v1 `status` | v2 `status`                                                                                                                                                                        |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `created`   | `created`                                                                                                                                                                          |
| `approved`  | `approved`                                                                                                                                                                         |
| `requested` | `requested`                                                                                                                                                                        |
| `completed` | `completed`, or `failed`, or `requested` (see below)                                                                                                                               |
| `deleted`   | — The payout is gone from v2: every endpoint answers 404 `PAYOUT_NOT_FOUND` for it, and lists do not include it. This also applies to payouts deleted through v1 or the dashboard. |
| `expired`   | `expired`                                                                                                                                                                          |

`failed` is new. It applies to a payout that you pushed to a counterparty that you manage, after the provider returned the transfer and Talentir processed the return. Until then, the payout reads `requested` in both versions. v1 shows a `failed` payout as `completed`. `failureCode` and `failureReason` say why. Fix the counterparty, then call `pay` again. The payout goes back to `requested` in v2 and stays `completed` in v1.

A returned transfer to a recipient who claimed stays `completed` in both versions. The recipient claims again with new details.

### Payment method values [#payment-method-values]

| v1 `toMethod` | v2 `paymentMethod.type`                              |
| ------------- | ---------------------------------------------------- |
| `bank-iban`   | `bank_iban`                                          |
| `bank-swift`  | `bank_swift`                                         |
| `bank-ach`    | `bank_ach`                                           |
| `bank-wire`   | `bank_wire`                                          |
| `bank-uk`     | `bank_uk`                                            |
| `paypal`      | `paypal`                                             |
| `venmo`       | `venmo`                                              |
| `crypto`      | `crypto`, with `paymentMethod.asset` (was `toAsset`) |

## The payout cycle [#the-payout-cycle]

In v2, each step is a request you make. The v1 API never emailed the recipient either: with `notifications: "allowed"`, a team member sent the claim link from the dashboard. In v2, Talentir emails the claim link when you call `send-claim-link`, and sends an SMS too when the payout has a phone number. While the payout stays unclaimed, Talentir sends one reminder 7 days after the latest claim-link email. A second `send-claim-link` resends the email and moves the reminder, if the reminder has not gone out yet.

`send-claim-link` needs status `approved`, an `availableOn` that is today or earlier, and a recipient email. In another status, it fails with 409 `PAYOUT_NOT_CLAIMABLE`. For a counterparty recipient, the email is the counterparty's `email`; without one, the call fails with 422 `RECIPIENT_EMAIL_MISSING`. A handle or wallet payout that v1 created without an email fails the same way; mint a link for it with `POST /sessions`.

`recipientNotifications` says whether Talentir may notify the recipient at all. With the default, `enabled`, a team member can also send the claim link from the dashboard, as in v1. With `disabled`, Talentir sends the recipient nothing and the dashboard cannot send the claim link: your system shows or sends a link minted with `POST /sessions`. A `send-claim-link` call sets it to `enabled`. Payouts that v1 created keep their v1 `notifications` setting and read back as the matching value. If a team member approves payouts in the dashboard and you want Talentir to email them, call `send-claim-link` when your webhook receives `payout.approved`, or leave it to the team member.

```bash
# 1. create
curl -X POST https://www.talentir.com/api/v2/payouts \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: payout-create:campaign-42:1" \
  -H "Content-Type: application/json" \
  -d '{ "customId": "campaign-42", "description": "September campaign", "amount": "100.00", "currency": "EUR", "recipient": { "type": "email", "email": "jane@example.com" } }'

# 2. approve (needs payouts:approve)
curl -X POST https://www.talentir.com/api/v2/payouts/campaign-42/approve \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: payout-approve:campaign-42:1"

# 3. let Talentir email the claim link
curl -X POST https://www.talentir.com/api/v2/payouts/campaign-42/send-claim-link \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: payout-send-claim-link:campaign-42:1"
```

For a payout to a counterparty, `POST /payouts/{id}/pay` pushes the money instead of step 3. `pay` on a payout to an email fails with 422 `RECIPIENT_NOT_COUNTERPARTY`.

The steps collapse with one field, `andThen`. On create, `"andThen": "approve"`, `"approve_and_send_claim_link"`, or `"approve_and_pay"` runs the later steps in the same request. On `approve`, the body `{ "andThen": "send_claim_link" }` or `{ "andThen": "pay" }` does the same. The request either records every step or none: a counterparty that cannot be paid fails the whole create with 422 `COUNTERPARTY_NOT_PAYABLE` and leaves no payout behind. The claim-link email goes out after the payout is saved. If the email fails, the payout stays `approved` and `claimLinkSentAt` stays `null`; call `send-claim-link` again.

* `andThen` on create needs `payouts:approve`. `approve` with `andThen: "pay"` also needs `payouts:write`.
* The `pay` variants need a counterparty recipient. On create, `andThen: "approve_and_pay"` without one fails with 400 `VALIDATION_FAILED`. On `approve`, `andThen: "pay"` without one fails with 422 `RECIPIENT_NOT_COUNTERPARTY`.
* Before a future `availableOn`, `pay`, `send-claim-link`, and every `andThen` that includes one of them fail with 409 `PAYOUT_NOT_YET_AVAILABLE`. Use `andThen: "approve"`, then call `pay` or `send-claim-link` on or after `availableOn`.

```bash
# create, approve, and pay in one request
curl -X POST https://www.talentir.com/api/v2/payouts \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: payout-create:INV-42:1" \
  -H "Content-Type: application/json" \
  -d '{ "customId": "INV-42", "description": "September campaign", "amount": "100.00", "currency": "EUR", "recipient": { "type": "counterparty", "counterpartyId": "counterparty_7c1d…" }, "andThen": "approve_and_pay" }'
```

If you send your own emails, skip step 3 and mint a URL with `POST /sessions` (`type: "payout_claim"`), as with v1 `POST /session/payout`.

## Counterparties [#counterparties]

A counterparty is the person or company you pay. It holds the name, address, payment method, and tax identity. The counterparties you picked with `counterpartyId` in v1 are the same records in v2. Add the `counterparty_` prefix to a stored `counterpartyId`.

To create one:

1. Read the recipient's country from the country list:

   ```
   GET /api/v2/countries
   ```

   The identity and address fields of a counterparty are fixed; the tax identity and the payment method are not. Each country has `taxRequirements.individual` and `taxRequirements.organization`. Each of these has `fields`, an ordered list: each field has a `key` (the `taxIdentity` field name), a `label`, help text in `localName`, `format`, and `description` (each or `null`), a `pattern` (or `null`), and an `example` (or `null`). It also has `anyOf`, the accepted combinations: send every field of any one set. An empty `anyOf` means no tax data is required. The rules already reflect your team's DAC7 setting; you never evaluate DAC7 yourself. See [Pay directly](/docs/v2/guides/pay-directly#1-read-the-country) for a full example.

2. Create the counterparty:

   ```bash
   curl -X POST https://www.talentir.com/api/v2/counterparties \
     -H "Authorization: Bearer $TOKEN" \
     -H "Idempotency-Key: counterparty-create:creator-1042:1" \
     -H "Content-Type: application/json" \
     -d '{
       "customId": "creator-1042",
       "identity": { "type": "individual", "firstName": "Jane", "lastName": "Doe" },
       "email": "jane@example.com",
       "address": { "line1": "Musterstraße 1", "city": "Berlin", "postalCode": "10115", "country": "DE" },
       "paymentMethod": { "type": "bank_iban", "iban": "DE89370400440532013000", "currency": "EUR" },
       "taxIdentity": { "taxId": "12345678901", "dateOfBirth": "1990-05-04" }
     }'
   ```

   `identity` and `address` are required. `paymentMethod.currency` must be one of the `currencies` that `GET /payment-method-types` gives for that `type`. In the response, `missing` names the gaps a push payment still has (`payment_method`, `tax_identity`, `address`). An empty `missing` means `POST /payouts/{id}/pay` can succeed. `GET /counterparties?payable=true` lists only the counterparties with an empty `missing`. The list also filters by `type`, `country`, `paymentMethodType`, and `email`.

3. Create payouts with `"recipient": { "type": "counterparty", "counterpartyId": "counterparty_…" }` and pay them with `POST /payouts/{id}/pay`, or send a claim link if the counterparty has an `email`. `pay` to a counterparty whose `missing` is not empty fails with 422 `COUNTERPARTY_NOT_PAYABLE`, and `details.missing` lists the gaps.

Read the country when you build the form, not once at install. When a tax rule changes, the country changes and the API version does not.

A recipient who claims a payout keeps their own details. Their counterparty is in the list with `recipientManaged: true`, and `PATCH` and `DELETE` on it fail with 409 `COUNTERPARTY_MANAGED_BY_RECIPIENT`. To take over, create a counterparty with the same `email`. The new counterparty hides the recipient-managed one in `GET /counterparties`. Payouts that you already created keep their counterparty. Use the new id for new payouts.

## Sessions [#sessions]

| v1                                        | v2                                                            | Notes                                                                                                                                                                                  |
| ----------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /session/payout` with `payoutId`    | `POST /sessions` with `type: "payout_claim"`, `payoutId`      | `payoutId` is `payout_…` or your `customId`.                                                                                                                                           |
| `POST /session/approval` with `payoutIds` | `POST /sessions` with `type: "payout_approval"`, `payoutIds`  | 1 to 100 ids, each once. Each id is `payout_…` or your `customId`. A payout that is not `created` fails with 409 `PAYOUT_NOT_APPROVABLE`, with `index`.                                |
| `POST /session/kyb`                       | `POST /sessions` with `type: "kyb"`                           |                                                                                                                                                                                        |
| `POST /session/allowance`                 | `POST /sessions` with `type: "allowance"`                     |                                                                                                                                                                                        |
| `redirectUrl`, `branding`, `loginHint`    | `redirectUrl`, `branding`, `loginHint`                        | `loginHint` on `payout_claim` is rejected with 400. `redirectUrl` must be `https`; see [Requests](#requests).                                                                          |
| `whitelabel`                              | —                                                             | v1 ignored it. v2 rejects it with 400. Use `branding: "team"`, which needs the white-label feature and a team subdomain; without them, the call fails with 403 `BRANDING_UNAVAILABLE`. |
| response `{ url }`, status 200            | `{ object: "session", id, type, url, createdAt }`, status 201 | Redirect the visitor to `url`. The URL does not expire.                                                                                                                                |

## Team [#team]

| v1 `GET /team`                                                                                                                                                                                           | v2                                                                | Notes                                                                                                                                                                                                                                                                                                                                     |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`, `teamUuid`                                                                                                                                                                                         | `GET /team`: `id`                                                 | `team_…`.                                                                                                                                                                                                                                                                                                                                 |
| `name`, `kybStatus`                                                                                                                                                                                      | `GET /team`: `name`, `kybStatus`                                  | `kybStatus` values: `pending` becomes `not_submitted`, and `completed` becomes `verified`. `in_review` and `denied` stay. A KYB check that ended in an error read `pending` in v1 and reads `in_review` in v2. New: `legalName`, `address`, `missing` (the steps left before the team can approve payouts), `createdAt`, and `updatedAt`. |
| `members[]`                                                                                                                                                                                              | `GET /team/members`: `data[]`                                     | Paged. Same `email`, `name`, `role`, `joinedAt`.                                                                                                                                                                                                                                                                                          |
| `balances[].currency`, `balances[].balance`                                                                                                                                                              | `GET /team/accounts`: `data[].currency`, `data[].balance.total`   | One account for every payout currency, also with a zero balance. Not paged. Amounts have two decimals.                                                                                                                                                                                                                                    |
| `balances[].openPayoutAmount`                                                                                                                                                                            | `data[].balance.reserved`                                         | The open payouts, converted to the currency of the account expected to pay them. A team with one payout currency keeps them all on that account, and the other accounts show `"0.00"` where v1 showed `null`.                                                                                                                             |
| `balances[].balanceAfterPendingPayouts`                                                                                                                                                                  | `data[].balance.available`                                        | `total` minus `reserved`.                                                                                                                                                                                                                                                                                                                 |
| `balances[].isActivePayout`                                                                                                                                                                              | `data[].payoutsEnabled`, and `GET /team`: `defaultPayoutCurrency` | `payoutsEnabled` marks every account that payouts take from. For a team with one payout currency, it equals `isActivePayout`. `defaultPayoutCurrency` is the currency of the account that was active.                                                                                                                                     |
| `balances[].allowance`                                                                                                                                                                                   | `data[].allowance`                                                | `limit` and `used` have two decimals. `nextResetAt` becomes `resetsAt`. `remaining` is gone: it is `limit` minus `used`. `null` while no allowance is set. To set it, use `POST /sessions` with `type: "allowance"`.                                                                                                                      |
| `deposits[].currency`, `deposits[].rails[]`                                                                                                                                                              | `data[].currency`, `data[].depositInstructions[]`                 | `type` uses the payment method type names: `sepa` becomes `bank_iban`, `faster-payments` becomes `bank_uk`, `ach` becomes `bank_ach`, `us-wire` becomes `bank_wire`, `swift` becomes `bank_swift`. The other rail fields keep their names. An absent bank field is `null`.                                                                |
| `dac7ReportingRequired`                                                                                                                                                                                  | —                                                                 | `GET /countries` already applies it.                                                                                                                                                                                                                                                                                                      |
| `totalBalance`, `totalBalanceCurrency`, `balances[].type`, `balances[].scheduledPayoutAmount`, `balances[].tokenSymbol`, `balances[].tokenAddress`, `balances[].walletAddress`, `balances[].depositInfo` | —                                                                 | Gone, with no v2 field in their place.                                                                                                                                                                                                                                                                                                    |

## Lists [#lists]

v1 `GET /payouts` returned a bare array and paged with `offset` and `limit`. v2 lists return an envelope:

```json
{ "object": "list", "data": [ … ], "hasMore": true, "nextCursor": "eyJ…" }
```

| v1 query                       | v2 query                                                                                                                                                       |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `offset`                       | `cursor`: the `nextCursor` of the previous page. Do not build or decode it. v2 ignores `offset`.                                                               |
| `limit` (1 to 100, default 50) | `limit` (1 to 100, default 20)                                                                                                                                 |
| `order_by`, `order_direction`  | — Always newest first. v2 ignores both.                                                                                                                        |
| —                              | `status` (repeat for several), `recipientCounterpartyId` (id or `customId`), `tag` (repeat; any match), `createdFrom` (inclusive), `createdBefore` (exclusive) |

Send the same filters, in the same order, with every `cursor`. A cursor with other filters fails with 400 `VALIDATION_FAILED`. `limit` can change between pages.

`/payouts`, `/counterparties`, `/webhooks`, `/webhooks/{id}/deliveries`, and `/team/members` page with a cursor. `/countries`, `/payment-method-types`, `/team/accounts`, and batch responses are never paged. They have no `hasMore` and no `nextCursor`.

## Errors [#errors]

Every error has `code`, `message`, and `status`. `details` is present only for codes that have it. In a batch, an error about one item also has `index`, the 0-based position of that item. An error about the whole request has no `index`: `INSUFFICIENT_BALANCE`, `VERIFICATION_REQUIRED`, `ALLOWANCE_REQUIRED`, `MISSING_SCOPE`, and the idempotency codes. A body that fails the schema has no `index` either; the position is in `details.issues[].path`.

```json
{ "error": { "code": "INSUFFICIENT_BALANCE", "message": "…", "status": 422, "details": { "total": "1000.00", "required": "1200.00", "currency": "EUR" } } }
```

Branch on `code`, never on `message`.

| v1                                                                                                   | v2                                                                                                                                                         |
| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `INPUT_VALIDATION_FAILED`, 422, `data.fieldErrors`                                                   | `VALIDATION_FAILED`, 400, `details.issues[]` with `path` (an array of segments; an array index is a number, e.g. `["payouts", 1, "amount"]`) and `message` |
| `BAD_REQUEST`, the body is not JSON                                                                  | `MALFORMED_REQUEST`, 400                                                                                                                                   |
| `BAD_REQUEST`, counterparty not found                                                                | `COUNTERPARTY_NOT_FOUND`, 404                                                                                                                              |
| `BAD_REQUEST`, `availableOn` is not in the future                                                    | `VALIDATION_FAILED`, 400, path `["availableOn"]`                                                                                                           |
| `BAD_REQUEST`, payout already exists                                                                 | `CUSTOM_ID_TAKEN`, 409, `details.customId`, `details.id`                                                                                                   |
| `UNAUTHORIZED`, 401                                                                                  | `UNAUTHORIZED`, 401                                                                                                                                        |
| `FORBIDDEN`, missing scope (also `preApproved` without `payouts:approve`)                            | `MISSING_SCOPE`, 403                                                                                                                                       |
| `FORBIDDEN`, team not verified, on `preApproved`                                                     | `VERIFICATION_REQUIRED`, 422, `details.missing`                                                                                                            |
| `FORBIDDEN`, no allowance, on `preApproved`                                                          | `ALLOWANCE_REQUIRED`, 422                                                                                                                                  |
| `NOT_FOUND`, payout                                                                                  | `PAYOUT_NOT_FOUND`, 404                                                                                                                                    |
| 500, webhook not found                                                                               | `WEBHOOK_NOT_FOUND`, 404                                                                                                                                   |
| Plain-text 404: no endpoint matches the method and path                                              | `NOT_FOUND`, 404                                                                                                                                           |
| `CONFLICT`, update of a `manual-immutable` payout                                                    | `PAYOUT_LOCKED`, 409, `details.status`, for a `created` payout. In another status: `PAYOUT_NOT_EDITABLE`, 409.                                             |
| `CONFLICT`, delete                                                                                   | `PAYOUT_NOT_DELETABLE`, 409, `details.status`                                                                                                              |
| `CONFLICT`, KYB session after the application was submitted                                          | `KYB_ALREADY_SUBMITTED`, 409                                                                                                                               |
| `FORBIDDEN`, white-label branding not available on a session                                         | `BRANDING_UNAVAILABLE`, 403                                                                                                                                |
| `BAD_REQUEST`, approval session with a payout that is unknown or not pending approval                | `PAYOUT_NOT_FOUND`, 404, or `PAYOUT_NOT_APPROVABLE`, 409, `details.status`; both with `index`                                                              |
| `INSUFFICIENT_BALANCE`, 422, `data.walletBalance`, `data.totalOpenAmount` (numbers), `data.currency` | `INSUFFICIENT_BALANCE`, 422, `details.total`, `details.required` (decimal strings), `details.currency`                                                     |
| `INTERNAL_SERVER_ERROR`, 500                                                                         | `INTERNAL_ERROR`, 500                                                                                                                                      |

New codes in v2:

| Code                                | Status | `details`                                                 |
| ----------------------------------- | ------ | --------------------------------------------------------- |
| `PAYOUT_NOT_EDITABLE`               | 409    | `status`                                                  |
| `PAYOUT_NOT_APPROVABLE`             | 409    | `status`                                                  |
| `PAYOUT_NOT_PAYABLE`                | 409    | `status`                                                  |
| `PAYOUT_NOT_CLAIMABLE`              | 409    | `status`                                                  |
| `PAYOUT_NOT_YET_AVAILABLE`          | 409    | `availableOn`                                             |
| `RECIPIENT_EMAIL_MISSING`           | 422    | `counterpartyId`, `null` for a v1 handle or wallet payout |
| `RECIPIENT_NOT_COUNTERPARTY`        | 422    |                                                           |
| `COUNTERPARTY_NOT_PAYABLE`          | 422    | `counterpartyId`, `missing`                               |
| `COUNTERPARTY_MANAGED_BY_RECIPIENT` | 409    |                                                           |
| `COUNTERPARTY_TYPE_FIXED`           | 409    |                                                           |
| `COUNTERPARTY_IN_USE`               | 409    |                                                           |
| `IDEMPOTENCY_KEY_REUSED`            | 409    |                                                           |
| `IDEMPOTENCY_KEY_IN_PROGRESS`       | 409    |                                                           |

An invalid VAT number has no code of its own. It is 400 `VALIDATION_FAILED` with the path `["taxIdentity", "vatNumber"]`.

## Webhooks [#webhooks]

v1 and v2 webhooks are separate. v1 webhooks keep receiving v1 bodies, and v2 webhooks receive v2 bodies. `GET /api/v2/webhooks` does not show v1 webhooks, and a v1 webhook id does not work in v2. Register a v2 webhook, run both while you migrate, then delete the v1 webhook with v1 `DELETE /api/v1/webhook/{id}`.

While both run, both receive changes made through either version or the dashboard. v1 bodies keep the bare ids. The webhook `url` must be a public `https` URL; to test from your machine, use a tunnel. v2 does not follow redirects; v1 followed up to 20. Register the final URL.

A v2 webhook belongs to the API key or OAuth grant that created or last updated it. It receives an event only while that credential can read the resource. If the key is deleted or disabled, loses the read scope, or the grant is revoked, the webhook stops receiving events and stays `enabled`. v1 webhooks belong to the team. When you rotate an API key, send `PATCH /webhooks/{id}` with the new key for each webhook, then delete the old key.

```bash
curl -X POST https://www.talentir.com/api/v2/webhooks \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: webhook-create:1" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/talentir", "events": ["payout.*", "counterparty.*"] }'
```

| v1                                                      | v2                                                  |
| ------------------------------------------------------- | --------------------------------------------------- |
| `POST /webhook` with `targetUrl`, `eventType: "payout"` | `POST /webhooks` with `url`, `events: ["payout.*"]` |
| response `{ id, signingSecret }`                        | the webhook, with `secret`                          |
| `GET /webhook`: `{ webhooks: [{ id, targetUrl }] }`     | a paged list of webhooks                            |
| `DELETE /webhook/{id}`: `{ id }`                        | `{ object: "webhook", id, deleted: true }`          |

The create response has the signing secret as `secret`. You see it once; it is `null` everywhere else except the response of `rotate-secret`. It is a new secret: v2 deliveries do not use your v1 secret. Both start with `whsec_`, but they are used in different ways. In v1, the full secret is the HMAC key. In v2, the part after `whsec_` is a base64 key, as [Standard Webhooks](https://www.standardwebhooks.com) defines it.

v2 records events only while you have an enabled v2 webhook. Changes before you register it are not sent.

### Event body [#event-body]

A v1 body was the payout itself, in the v1 shape, with a `message` string. A v2 body is an event envelope:

```json
{
  "object": "event",
  "id": "event_…",
  "type": "payout.completed",
  "teamId": "team_…",
  "timestamp": "2026-09-24T10:00:00.000Z",
  "apiVersion": "v2",
  "data": { "object": "payout", "id": "payout_…", "status": "completed", "…": "…" }
}
```

`data` has the v2 payout shape. Map its fields with the [field map](#payout-field-map). `timestamp` is when Talentir recorded the event, shortly after the change. `message` is gone.

v1 sent one `payout` event, and you read `status`. v2 names the transition in `type`:

| v1 `status` in the body | v2 `type`                                                                                                                                                       |
| ----------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `created`               | `payout.created`. A payout that goes back to `created` sends `payout.updated`.                                                                                  |
| `approved`              | `payout.approved`, when a `created` payout is approved. A payout that is created already approved (v1 `preApproved`, v2 `andThen`) sends only `payout.created`. |
| `requested`             | `payout.requested`                                                                                                                                              |
| `completed`             | `payout.completed`, or `payout.failed` for a returned push payout                                                                                               |
| `deleted`               | `payout.deleted`, with `data` `{ object, id, deleted: true }`                                                                                                   |
| `expired`               | `payout.expired`                                                                                                                                                |
| —                       | `payout.updated`, `counterparty.created`, `counterparty.updated`, `counterparty.deleted`, `webhook.ping`                                                        |

* `payout.updated` is sent when the description, amount, currency, tags, `customId`, `availableOn`, recipient, or `locked` changes, and when a payout goes back to `created`. v1 sent nothing for a change that kept the status.
* `webhook.ping` is sent by `POST /webhooks/{id}/test` to the webhook, whatever its `events`, with the webhook as `data`.
* Deduplicate on the event `id`, which is also the `webhook-id` header. A retry sends the same id.
* Events can arrive out of order, and one request can send several events: `andThen: "approve_and_pay"` sends `payout.created` and `payout.requested`. `data` is the payout when Talentir records the event, so it can already show a later step: here, the `payout.created` event has `status: "requested"`. For the current state, read `GET /payouts/{id}`; a deleted payout answers 404.
* `pay` on a `failed` payout sends `payout.requested`, then `payout.completed` or another `payout.failed`.

### Signature [#signature]

v2 webhooks follow [Standard Webhooks](https://www.standardwebhooks.com). v1 webhooks keep their scheme unchanged. In both, verify the raw body, not a re-serialized one.

|                 | v1                                             | v2                                                                                                                 |
| --------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Headers         | `X-Talentir-Signature`, `X-Talentir-Timestamp` | `webhook-id`, `webhook-timestamp`, `webhook-signature`                                                             |
| Signed content  | `{timestamp}.{body}`                           | `{webhook-id}.{webhook-timestamp}.{body}`                                                                          |
| HMAC-SHA256 key | The full secret, `whsec_` included             | The base64-decoded part of the secret after `whsec_`                                                               |
| Signature       | Lowercase hex, no prefix                       | Base64 with the prefix `v1,`. The header is a space-separated list; after a rotation, it has one entry per secret. |

The `X-Talentir-*` headers are not sent on v2 webhooks. An official Standard Webhooks library verifies a v2 request for you:

```ts
import { Webhook } from "standardwebhooks";

const event = new Webhook(secret).verify(rawBody, headers); // throws when the request is not valid
```

Each retry has a new `webhook-timestamp` and a new signature, so deduplicate on `webhook-id`, not on the signature. See [Verify signatures](/docs/v2/guides/webhooks#verify-signatures) for a check without a library and the timestamp age to accept, and [Delivery behavior](/docs/v2/guides/webhooks#delivery-behavior) for the retry schedule.

Give the v2 webhook its own URL. If you must use one URL for both versions, a v2 request has the `webhook-signature` header, and a v1 request has `X-Talentir-Signature`. Verify each with its own secret.

### Failing endpoints [#failing-endpoints]

v1 did not retry a 4xx answer other than 429. It removed a webhook when your endpoint answered 410, or after 25 failed events over at least 5 days with no successful delivery in between. v2 retries every answer that is not 2xx, 4xx included, for about three days. It sets `enabled: false` instead of removing the webhook: at once on 410, or after 25 events in a row that fail every attempt, over at least 5 days. Answer 2xx to event types you do not handle, or the webhook is disabled.

Fix the endpoint, then send `PATCH /webhooks/{id}` with `{ "enabled": true }`. Events that happened while the webhook was disabled are not sent later. Read their current state with `GET /payouts`.

## Scopes [#scopes]

| v1                                                               | v2                                                                                        |
| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| `payouts:read`, `payouts:approve`                                | unchanged                                                                                 |
| `payouts:write`                                                  | Also sends claim links and pays approved payouts. Approval still needs `payouts:approve`. |
| —                                                                | `counterparties:read`, `counterparties:write`, `transactions:read`                        |
| `team:read`, `webhooks:read`, `webhooks:write`, `sessions:write` | unchanged                                                                                 |

| v2 endpoint                                                                                                                           | Scope                                                                                                                                                                                                       |
| ------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /payouts`, `GET /payouts/{id}`                                                                                                   | `payouts:read`                                                                                                                                                                                              |
| `POST /payouts`, `POST /payouts/batch`, `PATCH /payouts/{id}`, `DELETE /payouts/{id}`, `send-claim-link` and `pay` (single and batch) | `payouts:write`                                                                                                                                                                                             |
| `andThen` on create                                                                                                                   | also `payouts:approve`                                                                                                                                                                                      |
| `pay` on a `failed` payout                                                                                                            | also `payouts:approve`                                                                                                                                                                                      |
| `POST /payouts/{id}/approve`, `POST /payouts/approve`                                                                                 | `payouts:approve`. With `andThen: "pay"`, also `payouts:write`. With `andThen: "send_claim_link"`, `payouts:approve` alone.                                                                                 |
| `GET /transactions`, `GET /transactions/{id}`                                                                                         | `transactions:read`                                                                                                                                                                                         |
| `GET /counterparties`, `GET /counterparties/{id}`                                                                                     | `counterparties:read`                                                                                                                                                                                       |
| `GET /countries`, `GET /payment-method-types`                                                                                         | `counterparties:read` or `counterparties:write`                                                                                                                                                             |
| `POST`, `PATCH`, and `DELETE` on `/counterparties`                                                                                    | `counterparties:write`                                                                                                                                                                                      |
| `GET /webhooks`, `GET /webhooks/{id}`, `deliveries`                                                                                   | `webhooks:read`                                                                                                                                                                                             |
| `POST`, `PATCH`, and `DELETE` on `/webhooks`, `rotate-secret`, `test`                                                                 | `webhooks:write`                                                                                                                                                                                            |
| `POST` and `PATCH` on `/webhooks`                                                                                                     | also `payouts:read` for payout events, `counterparties:read` for counterparty events, and `transactions:read` for transaction events. `PATCH` checks the webhook's events also when you do not change them. |
| `POST /sessions`                                                                                                                      | `sessions:write`                                                                                                                                                                                            |
| `GET /team`, `GET /team/members`, `GET /team/accounts`                                                                                | `team:read`                                                                                                                                                                                                 |

An existing API key has no `counterparties:*` or `transactions:read` scope. Add them in the dashboard under API keys, or create a new key. OAuth clients request the new scopes on `/authorize`, so the user must authorize again; see [Base URL and authentication](#base-url-and-authentication) for the `resource` to request. `payouts:approve` is not self-service: Talentir enables it for your team. Without it, approve in the dashboard or with a `payout_approval` session.

## Idempotency [#idempotency]

v2 accepts `Idempotency-Key` on every `POST`. The header is optional, but without it a retry runs the request again. v1 ignores the header, and v2 ignores it on `PATCH` and `DELETE`.

* The key has 1 to 255 characters. It is scoped to your team, the method, and the exact path. `/payouts/INV-42/approve` and `/payouts/payout_…/approve` are different paths for the key.
* The same key on the same path replays the stored response with `Idempotent-Replayed: true` for at least 24 hours. The same key with a different body fails with 409 `IDEMPOTENCY_KEY_REUSED`.
* A second request while the first one still runs fails with 409 `IDEMPOTENCY_KEY_IN_PROGRESS`. Retry later with the same key.
* The stored response includes errors. A 4xx from the endpoint's own checks is stored and replayed, and so is a 500 `INTERNAL_ERROR`, because the request can have done part of its work. Not stored: `MISSING_SCOPE`, `UNAUTHORIZED`, and a body that is not JSON or does not match the schema.

The key and `customId` do different jobs. `customId` says which payout this is, forever. The key says which request attempt this is, for 24 hours. For a create, derive the key from your business key and an attempt number, for example `Idempotency-Key: payout-create:INV-42:1`. Retry a network failure with the same key. After a stored error, fix the cause, for example add balance after `INSUFFICIENT_BALANCE`, and send a new key (`payout-create:INV-42:2`). After a 500, first check the state with `GET /payouts/INV-42`, then send a new key if the payout is still missing. A retry after 24 hours gets 409 `CUSTOM_ID_TAKEN` with the payout's id. Both end on the same payout.

If you do not store an attempt number, send a new random key for each attempt, and use `customId` to find a payout that already exists: `CUSTOM_ID_TAKEN` gives its id.

## Conventions in short [#conventions-in-short]

* Collections are plural nouns: `/payouts`. The team is a singleton: `/team`. Operations are verb segments: `/payouts/{id}/approve`.
* Path segments are kebab-case, JSON fields are camelCase, enum values are snake\_case, error codes are SCREAMING\_SNAKE\_CASE. Event types are dotted: `payout.completed`.
* Money is a decimal string with a `currency` field next to it.
* Timestamps end in `At`, except the event `timestamp`, and are ISO 8601 UTC with milliseconds. Calendar dates are `YYYY-MM-DD`.
* Most resources have `object`, `id`, `createdAt`, and `updatedAt`. A session has `createdAt` only. A team member has no `id` and only `joinedAt`. An account has no `id` and no timestamps. Countries and payment method types have no timestamps. An event has `timestamp`.
* Top-level fields of a resource are never omitted; they are `null`. Arrays are never `null`, except `currencies` and `assets` in `GET /payment-method-types`: `currencies` is `null` for `crypto`, and `assets` is `null` for a fiat rail.
* Ignore fields you do not know. Treat every enum that the reference marks as open as open, with a fallback. These include `status`, `failureCode`, `recipient.type`, `paymentMethod.type`, `currency` on read, `missing`, `kybStatus`, `role`, `depositInstructions[].type`, and event `type`. On write, a payout `currency` is exactly `USD`, `EUR`, `CHF`, or `GBP`. A counterparty's `paymentMethod.currency` is one of the `currencies` of its type. See [Changes without a new version](/docs/v2#changes-without-a-new-version) for what can change.

## Checklist [#checklist]

1. Switch the base URL to `/api/v2`. Test in the sandbox first.
2. Add `counterparties:*` to your API key. OAuth clients authorize again, with `resource=<base_url>/api/v1` while they still call v1. Ask Talentir for `payouts:approve` if you approve through the API.
3. Prefix stored ids: `payout_`, `counterparty_`, `team_`.
4. Rename fields per the [field map](#payout-field-map). Send `amount`, not `payoutAmount`. Send `locked`, not `payoutType`. Send `andThen: "approve"`, not `preApproved`.
5. Replace `verificationMethod`, `email`, `phone`, and `counterpartyId` with the `recipient` object. Stop creating handle and wallet payouts.
6. Remove every v1-only field from request bodies. v2 rejects unknown fields. Check the new limits in [Requests](#requests).
7. Replace update-by-`POST` with `PATCH /payouts/{id}`. It works only while `status` is `created` and changes only the fields you send.
8. Add the step after approval: `send-claim-link`, `pay`, or your own link from `POST /sessions`.
9. Map `status`, `paymentMethod.type`, and `kybStatus` values. Handle the new status `failed`. Treat open enums as open.
10. Expect 201 on every create, and `null` instead of omitted fields.
11. Replace `offset` paging with `cursor`. v2 ignores `offset`. Read `data` from the list envelope.
12. Read `error.code` from the new error shape. Move validation handling from 422 to 400.
13. Move sessions to `POST /sessions` with `type`. Send prefixed payout ids or your `customId`s.
14. Read balances from `GET /team/accounts` and members from `GET /team/members`.
15. Register a v2 webhook with `events[]`. Store its `secret`. Verify each request with a Standard Webhooks library. Read `type` and `data` from the envelope. Deduplicate on `id`. Answer 2xx to event types you do not handle. After an API key rotation, `PATCH` the webhook with the new key.
16. Send `Idempotency-Key` on every `POST`. Retry a network failure with the same key. After a 500, check the state before you send a new key.
17. Delete the v1 webhook with v1 `DELETE /api/v1/webhook/{id}` once the v2 webhook is verified. Use a v1 API key or a token bound to `/api/v1`.


# Pay directly (/docs/v2/guides/pay-directly)



You already hold the recipient's details, or you collect them in your own form. Save the recipient as a counterparty: a person or company in your team's book with name, address, payment method, and tax identity. A payout addressed to a counterparty is pushed to its saved payment method with `POST /payouts/{id}/pay`. No claim link, no recipient sign-in, no Talentir screen.

## 1. Read the country [#1-read-the-country]

The identity and address fields of a counterparty are fixed; the tax identity and the payment method are not. Which tax fields a counterparty needs depends on its country and type, and on your team's DAC7 setting. Do not hardcode them: read them from `GET /countries` when you build your form, and use the entry whose `id` is the address country.

```bash
curl "$BASE_URL/countries" \
  -H "Authorization: Bearer $TALENTIR_API_KEY"
```

```json
{
  "object": "list",
  "data": [
    …,
    {
      "object": "country",
      "id": "DE",
      "name": "Germany",
      "taxRequirements": {
        "individual": {
          "fields": [
            {
              "key": "taxId",
              "label": "Tax identification number",
              "localName": "Steuernummer",
              "format": "10 to 13 digits, e.g. 123/456/78901",
              "description": "Use your Steuernummer for invoices and tax reporting. It is different from your personal Steuer-ID. …",
              "pattern": "^(\\d{10,13}|\\d{2,3}\\/\\d{3,4}\\/\\d{4,5})$",
              "example": null
            },
            { "key": "vatNumber", "label": "VAT number", "localName": null, "format": null, "description": null, "pattern": "^(DE)\\d{9}$", "example": "DE123456789" },
            { "key": "dateOfBirth", "label": "Date of birth", "localName": null, "format": null, "description": null, "pattern": null, "example": null }
          ],
          "anyOf": [["taxId", "dateOfBirth"]]
        },
        "organization": {
          "fields": [
            { "key": "taxId", "label": "Tax identification number", "localName": "Steuernummer of the company (or W-IdNr)", "format": "…", "description": "…", "pattern": "…", "example": null },
            { "key": "vatNumber", "label": "VAT number", "localName": null, "format": null, "description": null, "pattern": "^(DE)\\d{9}$", "example": "DE123456789" },
            { "key": "registrationNumber", "label": "Registration number", "localName": "Handelsregisternummer", "format": "HRA or HRB and a number, e.g. HRB 12345", "description": "…", "pattern": "^[A-Z]{2,3}\\d{1,7}[A-Z]{0,2}$", "example": null }
          ],
          "anyOf": [["taxId", "registrationNumber"]]
        }
      }
    },
    …
  ]
}
```

* `taxRequirements.individual` and `taxRequirements.organization` each list the `fields` the country takes for that type, in the order your form shows them. Each field has the `key` to send in `taxIdentity`, a `label` for your form, a `pattern` the value must match after uppercasing and removing whitespace, and an `example` where one exists.
* `localName`, `format`, and `description` help the user fill in the field: what the country calls the number, what it looks like, and where to find it. Show them as help text next to the input. They are in English, and they are `null` where Talentir has no guidance. Today that is every field except the tax identification number and the registration number in EU countries.
* `anyOf` lists the accepted combinations as sets of `key`s. Send every field of any one set. In France, for example, an individual sends `taxId` and `dateOfBirth`, or `birthPlaceCity`, `birthPlaceCountry`, and `dateOfBirth`. An empty list means no tax data is required.
* A field in `fields` but in no set is optional, for example the VAT number.
* The rules already reflect your team's DAC7 setting; you never evaluate DAC7 yourself.

`GET /countries` lists every country a counterparty can live in, ordered by `id`. A country Talentir does not pay to is not listed, and a counterparty with an address there fails with `VALIDATION_FAILED`.

## 2. Create the counterparty [#2-create-the-counterparty]

```bash
curl -X POST "$BASE_URL/counterparties" \
  -H "Authorization: Bearer $TALENTIR_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "customId": "creator-1042",
    "identity": { "type": "individual", "firstName": "Jane", "lastName": "Doe" },
    "email": "jane@example.com",
    "address": { "line1": "Musterstraße 1", "city": "Berlin", "postalCode": "10115", "country": "DE" },
    "paymentMethod": { "type": "bank_iban", "iban": "DE89370400440532013000", "currency": "EUR" },
    "taxIdentity": { "taxId": "12345678901", "dateOfBirth": "1990-05-04" }
  }'
```

`identity.type` is `individual` (with `firstName` and `lastName`) or `organization` (with `name`), and cannot change after creation. `taxIdentity` is checked against the country's rules for `address.country` and `identity.type`; a violation is 400 `VALIDATION_FAILED` with one issue per failing path.

The response is the counterparty. An empty `missing` means `pay` can succeed. Otherwise `missing` names the gaps: `address`, `payment_method`, or `tax_identity`. To list only the counterparties you can pay, use `GET /counterparties?payable=true`. A counterparty can be saved incomplete and completed later with `PATCH /counterparties/{id}`, where a nested object (`address`, `paymentMethod`, `taxIdentity`) is replaced as a whole and `null` clears it.

### Choose the payment method [#choose-the-payment-method]

`paymentMethod.type` picks the rail: `bank_iban` (SEPA), `bank_swift`, `bank_ach`, `bank_wire` (Fedwire), `bank_uk` (Faster Payments), `paypal`, `venmo`, or `crypto`. Each type has its own account fields, documented in the reference. Which currencies a fiat rail settles in, and which tokens `crypto` pays out, can change without a new API version. Do not hardcode them: read them from `GET /payment-method-types` when you build your form.

```bash
curl "$BASE_URL/payment-method-types" \
  -H "Authorization: Bearer $TALENTIR_API_KEY"
```

```json
{
  "object": "list",
  "data": [
    { "object": "payment_method_type", "id": "bank_iban", "name": "SEPA / IBAN", "currencies": ["EUR", "USD", "CHF", "GBP", "CAD", "CZK", "AUD"], "assets": null },
    …,
    {
      "object": "payment_method_type",
      "id": "crypto",
      "name": "Crypto",
      "currencies": null,
      "assets": [
        { "id": "eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "symbol": "USDC", "network": "Base", "currency": "USD" },
        …
      ]
    }
  ]
}
```

* `id` is the value for `paymentMethod.type`, and `name` is a label for your form.
* `currencies` lists the codes a fiat rail takes as `paymentMethod.currency`. A currency the rail does not settle in is 400 `VALIDATION_FAILED` on `paymentMethod.currency`.
* `assets` lists the tokens `crypto` takes as `paymentMethod.asset`, each with its `network` and the `currency` it is pegged to. A token without a peg, such as ETH, has `currency: null`.

### Pay to a crypto wallet [#pay-to-a-crypto-wallet]

A `crypto` payment method takes the token to send as a CAIP-19 `asset`, and the `walletAddress` that receives it:

```json
"paymentMethod": {
  "type": "crypto",
  "asset": "eip155:8453/erc20:0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
  "walletAddress": "0x…"
}
```

Talentir checks that the address is valid on the asset's network, checksum included. An address that fails the check is 400 `VALIDATION_FAILED` on `paymentMethod.walletAddress`. An asset that is not in the `crypto` entry of `GET /payment-method-types` is 400 `VALIDATION_FAILED` on `paymentMethod.asset`. Talentir does not check who controls the wallet: you vouch for the address. A crypto transfer cannot be reversed, so confirm the address with the recipient before you pay.

A token without a peg (ETH, SOL, BTC) reads back with `paymentMethod.currency: null`. A payout to it sends the value of its `amount` in that token.

## 3. Create the payout and pay it [#3-create-the-payout-and-pay-it]

```bash
curl -X POST "$BASE_URL/payouts" \
  -H "Authorization: Bearer $TALENTIR_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "customId": "INV-42",
    "description": "Invoice 42",
    "amount": "100.00",
    "currency": "EUR",
    "recipient": { "type": "counterparty", "counterpartyId": "counterparty_7c1d2f30-…" },
    "andThen": "approve_and_pay"
  }'
```

The payout fields are the same as for a [payout link](/docs/v2/guides/pay-with-payout-link#1-create-the-payout); only the `recipient` differs.

`andThen: "approve_and_pay"` creates, approves, and pays in one request. It needs `payouts:approve` and `payouts:write`, and either does all three steps or none: a counterparty whose `missing` is not empty fails the whole request with 422 `COUNTERPARTY_NOT_PAYABLE` and leaves no payout behind. The response is the payout in status `requested`.

Without `payouts:approve`, create the payout, have it [approved](/docs/v2/guides/approve-payouts), then call:

```bash
curl -X POST "$BASE_URL/payouts/INV-42/pay" \
  -H "Authorization: Bearer $TALENTIR_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

`pay` needs `payouts:write`: it moves what approval already reserved. Paying a `failed` payout again also needs `payouts:approve` (see [Handle the outcome](#4-handle-the-outcome)). It is refused with 409 `PAYOUT_NOT_PAYABLE` when the payout is not `approved` or `failed`, with 422 `RECIPIENT_NOT_COUNTERPARTY` when its recipient is not a counterparty, and with 409 `PAYOUT_NOT_YET_AVAILABLE` before `availableOn`. `pay` does not schedule; call it on the day.

A counterparty with an `email` can receive a [claim link](/docs/v2/guides/pay-with-payout-link#3-send-the-claim-link) instead of a push.

## 4. Handle the outcome [#4-handle-the-outcome]

Settlement is asynchronous. Subscribe to [webhooks](/docs/v2/guides/webhooks) for `payout.completed` and `payout.failed`, or poll `GET /payouts/{id}`.

A `failed` payout carries a `failureCode` to branch on (`account_closed`, `account_invalid`, `name_mismatch`, or `other`; an open enum) and a `failureReason` written for you, never shown to the recipient. The payout keeps its approval. Fix the counterparty with `PATCH /counterparties/{id}` and call `pay` again; the payout returns to `requested`. This second `pay` needs `payouts:approve` as well as `payouts:write`, because the money goes to the counterparty's current payment method, which the approval did not cover. Without `payouts:approve`, pay it again in the dashboard.

Changing a counterparty's address, payment method, or tax identity moves every `approved` payout to it back to `created`, because you now pay a different destination. Re-approve them afterwards.

In the sandbox, a counterparty whose `identity.lastName` or `identity.name` contains **SANDBOX FAIL**, whose PayPal email contains **sandbox-fail**, or whose wallet address is `0x000000000000000000000000000000000000dEaD`, makes the payout fail. A bank or PayPal failure becomes `failed` at once, with the `failureCode` `account_invalid`. See the [sandbox test scenarios](/docs/v2/guides/pay-with-payout-link#sandbox-test-scenarios).

## Recipient-managed counterparties [#recipient-managed-counterparties]

When a recipient claims a payout, Talentir keeps their details as a counterparty with `recipientManaged: true`. Such a counterparty refuses `PATCH` and `DELETE`, because the recipient owns the data. To take over, create a counterparty with the same `email`; it replaces the recipient-managed one in lists and on new payouts.

## Delete a counterparty [#delete-a-counterparty]

`DELETE /counterparties/{id}` is refused with 409 `COUNTERPARTY_IN_USE` while a payout to it is `created`, `approved`, `requested`, or `failed`.

After the delete, the counterparty is gone from the API. Every endpoint answers 404 `COUNTERPARTY_NOT_FOUND` for it, and lists do not include it. Payouts to it keep its id in `recipient.counterpartyId`. Its `customId` is free again for a new counterparty.


# Pay with a payout link (/docs/v2/guides/pay-with-payout-link)



You know the recipient's email address and nothing else. Create a payout for that email, get it approved, and hand the recipient a claim link. On the Talentir-hosted page they sign in with a one-time code, choose a payout method, and claim the money. Talentir collects the payment details and runs verification, so your product never stores them.

## 1. Create the payout [#1-create-the-payout]

```bash
curl -X POST "$BASE_URL/payouts" \
  -H "Authorization: Bearer $TALENTIR_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "customId": "campaign-42",
    "description": "Payment for September campaign",
    "amount": "100.00",
    "currency": "EUR",
    "recipient": { "type": "email", "email": "jane@example.com" },
    "tags": ["september"]
  }'
```

The response has status `201` and is the payout as `GET /payouts/{id}` returns it. Every field is present; a field with no value is `null`. The payout starts in status `created`.

Fields:

* `description` (required) — shown to the recipient. 1 to 5000 characters. It can span several lines: a literal `\n` becomes a line break, and consecutive line breaks collapse into one.
* `amount` (required) — a decimal string such as `"100.00"`, minimum `0.10`. Never a float.
* `currency` (required) — `USD`, `EUR`, `CHF`, or `GBP`.
* `recipient` (required) — `{ "type": "email", "email": "…" }`. No Talentir account is needed yet. Add an optional `phone` (E.164) when your team sends claim links by SMS. A saved [counterparty](/docs/v2/guides/pay-directly) with an email can also receive a claim link.
* `customId` — your own key, unique within your team. It works in place of the `payout_…` id wherever a payout is referenced, for example as `{id}` in a path. A second request with the same `customId` is refused. See [Idempotency](/docs/v2/concepts/idempotency).
* `availableOn` — a UTC calendar date (`YYYY-MM-DD`) before which the payout cannot be claimed. Omit for immediately claimable.
* `tags` — free-form strings for categorizing payouts; filterable with `?tag=` on `GET /payouts`. Up to 20 tags, each 1 to 100 characters.
* `locked` — `true` makes the payout refuse `PATCH` and dashboard edits. Only delete is left.
* `recipientNotifications` — `enabled` (the default) lets Talentir email the claim link on `send-claim-link` and lets a team member send it from the dashboard. `disabled` means Talentir sends the recipient nothing and the dashboard cannot send the claim link; use it when your own product shows or sends the link.
* `andThen` — runs the later steps in the same request: `approve` or `approve_and_send_claim_link`. Both need `payouts:approve`. The request either does every step or none. The claim link email goes out only after the approval is saved. If it cannot be sent, the payout stays `approved` with `claimLinkSentAt: null`; call `send-claim-link` again.

Send an `Idempotency-Key` header on every `POST`, so a retried request replays the response instead of creating a second payout.

Payouts to a TikTok, Instagram, or YouTube handle, or to a wallet address, cannot be created through v2. Those created through v1 read back with `recipient.type` of `tiktok`, `instagram`, `youtube_channel`, or `wallet`. To pay a wallet, save it as a counterparty's `crypto` payment method and [pay directly](/docs/v2/guides/pay-directly#pay-to-a-crypto-wallet).

For every parameter and the full response schema, see the [Payouts reference](/docs/v2/reference/payouts/payouts.create).

## 2. Get it approved [#2-get-it-approved]

A `created` payout waits for your team's approval, which reserves the amount against your balance. Approve it in the dashboard, on a hosted approval screen, or through the API. See [Approve payouts](/docs/v2/guides/approve-payouts).

With `payouts:approve`, `andThen: "approve_and_send_claim_link"` on create does steps 1 to 3 in one request.

## 3. Send the claim link [#3-send-the-claim-link]

An `approved` payout waits for you to send the link. Pick one:

**Let Talentir email it.**

```bash
curl -X POST "$BASE_URL/payouts/campaign-42/send-claim-link" \
  -H "Authorization: Bearer $TALENTIR_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)"
```

This emails the recipient and sets `claimLinkSentAt`. Talentir sends one reminder 7 days after the latest claim-link email. Calling it again resends the email and moves a reminder that has not gone out yet. It needs `payouts:write` and a recipient with an email.

**Send it yourself.** Create the payout with `recipientNotifications: "disabled"`, so that no one at your team sends a second link from the dashboard. Then mint the URL and put it in your own email, or redirect the recipient to it from your product:

```bash
curl -X POST "$BASE_URL/sessions" \
  -H "Authorization: Bearer $TALENTIR_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "payout_claim",
    "payoutId": "campaign-42",
    "redirectUrl": "https://yourapp.com/done"
  }'
```

The response carries a `url`. Treat it as opaque: redirect the recipient to it as is, and do not parse or build it. The URL confers no access on its own; the screen still authenticates the visitor, so the link is safe to email. `redirectUrl` is where the recipient returns after claiming or leaving the screen; it must be an absolute `https` URL (`http` only for localhost). Add `branding: "team"` to host the page on your white-label subdomain with your logo. See [Pay on behalf of your customers](/docs/v2/guides/platform-integrations#hosted-sessions) for every session field.

## 4. What the recipient sees [#4-what-the-recipient-sees]

The recipient signs in or signs up with their own email, chooses a payout method (bank account, PayPal, Venmo, or a crypto wallet), and claims. The claim moves the payout to `requested`, and Talentir requests the transfer. Talentir keeps the recipient's details as a counterparty with `recipientManaged: true`, so a later payout to the same email can be [paid directly](/docs/v2/guides/pay-directly).

By default Talentir issues a self-billing invoice in the recipient's name for every payout and returns it as `recipientInvoiceUrl`. With the `invoice_upload` feature enabled for your organization, recipients can upload their own invoice while claiming instead. Talentir reads the document and checks it against the payout: the amount and currency, the party it is addressed to, the issuer, and the VAT treatment. `recipientInvoiceSource` (`self_billing` or `recipient_upload`) tells you which document you got.

## 5. Track the payout [#5-track-the-payout]

Subscribe to [webhooks](/docs/v2/guides/webhooks) for `payout.approved`, `payout.requested`, and `payout.completed`, or poll `GET /payouts/{id}`. A provider rejection after the claim sends the recipient to a retry flow where they fix their details; see [Payout lifecycle](/docs/v2/concepts/payout-lifecycle).

## Manage payouts [#manage-payouts]

These apply to every payout, however it is sent.

**Create many at once.** `POST /payouts/batch` takes `{ "payouts": [ ... ] }` with up to 100 items, each a `POST /payouts` body. It creates all of them or none, and answers with a list in request order. See [Batches](/docs/v2/guides/approve-payouts#batches).

**Update.** `PATCH /payouts/{id}` changes a payout while its status is `created`. An omitted field is unchanged; `recipient` is replaced as a whole. A payout in another status is 409 `PAYOUT_NOT_EDITABLE`, a locked one 409 `PAYOUT_LOCKED`.

**Delete.** A payout can be deleted before any transfer starts: while its status is `created`, `approved`, or `expired`. Deleting emits `payout.deleted`.

```bash
curl -X DELETE "$BASE_URL/payouts/campaign-42" \
  -H "Authorization: Bearer $TALENTIR_API_KEY"
```

The response is `{ "object": "payout", "id": "payout_…", "deleted": true }`. A `requested`, `completed`, or `failed` payout cannot be deleted; the request fails with 409 `PAYOUT_NOT_DELETABLE`. After the delete, the payout is gone from the API: every endpoint answers 404 `PAYOUT_NOT_FOUND` for it, and lists do not include it. Its `customId` stays taken: a create with it fails with 409 `CUSTOM_ID_TAKEN`. To redo the payout, use a new `customId`.

**List.** `GET /payouts` returns a list envelope, newest first. Filter with `status` (repeatable), `recipientCounterpartyId`, `tag` (repeatable), `createdFrom` (inclusive), and `createdBefore` (exclusive). Page with `limit` (1 to 100, default 20) and the `nextCursor` of the previous page as `cursor`.

```json
{ "object": "list", "data": [ … ], "hasMore": true, "nextCursor": "eyJ…" }
```

## Sandbox test scenarios [#sandbox-test-scenarios]

In the sandbox, every payout method has a passing and a failing scenario. **Success is the default**: any regular recipient details settle the payout instantly and synchronously, including the webhook events a real payout would emit. To exercise the failure path, use these magic values on the claim screen, or on the counterparty for a [direct payout](/docs/v2/guides/pay-directly):

| Method                                                                                                            | Failure trigger                                                                                                                 |
| ----------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| SEPA (`bank_iban`), SWIFT (`bank_swift`), ACH (`bank_ach`), Fedwire (`bank_wire`), UK Faster Payments (`bank_uk`) | Account holder name containing **SANDBOX FAIL** (for a counterparty, its `identity.lastName` or `identity.name`)                |
| PayPal (`paypal`), Venmo (`venmo`)                                                                                | Recipient email containing **sandbox-fail** (e.g. `sandbox-fail@example.com`), or a Venmo phone number ending in **0000**       |
| Crypto (`crypto`)                                                                                                 | Destination wallet address `0x000000000000000000000000000000000000dEaD` (for a counterparty, its `paymentMethod.walletAddress`) |

A triggered failure behaves like a real provider rejection: the transfer is dispatched, then declined. For a bank, PayPal, or Venmo trigger, the sandbox then puts the payout on retry at once, with the code `account_invalid`. In production, Talentir Operations reviews each return before the retry starts, so this step can take some time. A claimed payout goes to the recipient's retry flow. A direct payout to a counterparty becomes `failed` with a `failureReason`, and `payout.failed` fires instead of `payout.completed`.


# Pay on behalf of your customers (/docs/v2/guides/platform-integrations)



A platform integration creates payouts for its customers, not for itself. Each customer has their own Talentir team, which holds the balance, carries the verification, and approves the payouts. Your product connects to that team through OAuth, and every Talentir screen the customer or their recipients need is a hosted session you mint and redirect to.

The steps below use `$ACCESS_TOKEN`, the OAuth token of the connected team, where a single-team integration would use an API key.

For a working example, see [Baton](https://github.com/Talentir/baton-oauth-demo), a minimal open-source Next.js app that runs this flow from connect to the first approved payout. You can try it at [baton.talentir.com](https://baton.talentir.com). To stay small, Baton keeps the client secret and tokens in the browser. A real platform keeps them on its server.

## The onboarding lifecycle [#the-onboarding-lifecycle]

A customer passes through five stages before their first payout can settle. Your product drives each stage and reads the team's state from the API in between.

| Stage                                           | Who acts                                                               | Your calls                                             | Done when                                                    |
| ----------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------ |
| [Connect](#2-connect-a-customers-team)          | The customer, on the OAuth screens                                     | `/authorize`, then `/token`                            | You hold tokens for the team                                 |
| [Verify](#3-get-the-team-verified)              | The team owner, on a hosted `kyb` screen                               | `GET /team`, `POST /sessions`                          | `kybStatus` is `verified`                                    |
| [Fund](#4-fund-the-balance)                     | The customer, at their bank                                            | `GET /team`, `GET /team/accounts`                      | `missing` is empty and `balance.available` covers the payout |
| [Pay](#5-create-the-first-payout)               | Your product                                                           | `POST /payouts`                                        | The payout is `created`                                      |
| [Approve and send](#6-let-the-customer-approve) | A team member, on a hosted `payout_approval` screen; then your product | `POST /sessions`, `POST /payouts/{id}/send-claim-link` | The payout is `completed`                                    |

Every stage can be re-entered. Each time the customer opens your integration page, read `GET /team` and `GET /team/accounts` and show the next step they need. Do not treat the return to your `redirectUrl` as proof that a hosted screen was completed: the customer can leave a screen early, and verification can still be in review.

## 1. Create an OAuth client [#1-create-an-oauth-client]

Go to **Settings → OAuth Clients** in your Talentir team dashboard, enter a name and your callback URL(s), and you receive a `client_id` and `client_secret` (shown once, with copy and download). Clients created there are attributed to your team: teams created through your authorize flow are credited to your platform, and hosted sessions can carry your white-label branding. Partner-program enrollment (referral terms, white-label) is handled by Talentir; contact us to enroll.

## 2. Connect a customer's team [#2-connect-a-customers-team]

### Redirect the customer to Talentir [#redirect-the-customer-to-talentir]

Generate a PKCE verifier and challenge and a random `state`, store both against the customer's session, and redirect the customer to the authorize endpoint:

```
https://www.talentir.com/api/auth/oauth2/authorize?response_type=code&client_id=<client_id>
  &redirect_uri=<redirect_uri>
  &scope=team:read payouts:read payouts:write counterparties:read counterparties:write webhooks:write sessions:write offline_access
  &code_challenge=<challenge>&code_challenge_method=S256
  &resource=https://www.talentir.com/api/v2
  &state=<state>
  &login_hint=finance@customer.com
  &team_name=Customer%20GmbH
```

On the hosted screens the customer signs in or signs up, selects or creates their team, and consents to the scopes.

* PKCE is mandatory, and `resource` must be sent on both `/authorize` and `/token`.
* `offline_access` gives you a refresh token, so the connection survives the access token's expiry.
* `login_hint` pre-fills the email and sends the one-time sign-in code right away.
* `team_name` pre-fills the team name when the customer creates or names a team. Pass the company name from your records, so the team is created under the right name.
* `team_hint` pre-selects a team when the customer connects a second time.

The protocol details, discovery document, and every hint are in [OAuth 2.1 with PKCE](/docs/v2/authentication/oauth). The scopes are listed in [Scopes and permissions](/docs/v2/concepts/scopes-and-permissions).

### Handle the callback [#handle-the-callback]

Talentir sends the customer back to your `redirect_uri` with `code` and `state` in the query string. Check that `state` matches what you stored, then exchange the code on your server:

```bash
curl -X POST "https://www.talentir.com/api/auth/oauth2/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=<code>" \
  -d "redirect_uri=<redirect_uri>" \
  -d "client_id=<client_id>" \
  -d "client_secret=<client_secret>" \
  -d "code_verifier=<verifier>" \
  -d "resource=https://www.talentir.com/api/v2"
```

```json
{
  "access_token": "eyJ…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "…",
  "scope": "team:read payouts:read payouts:write counterparties:read counterparties:write webhooks:write sessions:write offline_access"
}
```

Store the refresh token against the customer. The access token expires after an hour; get a new one with `grant_type=refresh_token`, the stored `refresh_token`, and the same `resource`. If the customer declines consent, the callback carries `error=access_denied` instead of a `code`.

### Read the connected team [#read-the-connected-team]

The token acts on the team the customer selected. Read it once and save its `id` with the customer record:

```bash
curl "$BASE_URL/team" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

```json
{
  "object": "team",
  "id": "team_4399dd07-cf1b-414b-bfbc-91a88dd73ec5",
  "name": "Customer GmbH",
  "legalName": "Customer GmbH",
  "address": {
    "line1": "Musterstraße 1",
    "line2": null,
    "city": "Berlin",
    "state": null,
    "postalCode": "10115",
    "country": "DE"
  },
  "kybStatus": "not_submitted",
  "missing": ["wallet", "kyb"],
  "defaultPayoutCurrency": "EUR",
  "createdAt": "2026-09-24T10:00:00.000Z",
  "updatedAt": "2026-09-24T10:00:00.000Z"
}
```

`kybStatus` and `missing` are the fields that drive the next stages. Register the team's [webhook](#8-receive-events) now as well, so the events of the first payout reach you.

## 3. Get the team verified [#3-get-the-team-verified]

A team cannot approve payouts until it has set up its wallet, completed business verification (KYB), and made its first deposit. Two fields of `GET /team` show how far the team is: `missing` and `kybStatus`.

`missing` lists every step the team must still complete before it can approve payouts. It is empty when nothing but the balance and the allowance blocks approval. It is an open enum: treat a value you do not know as a step that is left.

| Value           | Step                                                                                                                                                                                                                   |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `wallet`        | The owner sets up the team wallet                                                                                                                                                                                      |
| `kyb`           | Business verification                                                                                                                                                                                                  |
| `first_deposit` | The first bank deposit activates the verified account. It must come from a bank account in the team's registered name. `kybStatus` already reads `verified` at this point. See [Fund the balance](#4-fund-the-balance) |
| `address`       | The registered business address is incomplete                                                                                                                                                                          |
| `vat_number`    | An EU team has no VAT number, or it does not match the address country                                                                                                                                                 |

`kybStatus` is the state of the verification. Show the customer the matching state:

| `kybStatus`     | Meaning                                         | What your product shows                                                    |
| --------------- | ----------------------------------------------- | -------------------------------------------------------------------------- |
| `not_submitted` | The owner has not submitted the application yet | A button that sends the owner to the verification screen                   |
| `in_review`     | Submitted, Talentir is reviewing                | "Verification in review". Nothing to do; poll `GET /team` until it changes |
| `verified`      | Talentir verified the business                  | Continue to [funding](#4-fund-the-balance)                                 |
| `denied`        | Rejected                                        | Ask the customer to contact Talentir support                               |

While the status is `not_submitted`, mint the verification screen and redirect the owner to it:

```bash
curl -X POST "$BASE_URL/sessions" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "kyb",
    "redirectUrl": "https://yourapp.com/onboarding/verification",
    "branding": "platform"
  }'
```

The team owner completes verification on the hosted page. If the team has no wallet yet, the same screen first prompts the owner to create it (a passkey wallet), then continues. The sign-in code goes to the member who connected the team; set `loginHint` to another member's email when someone else should complete it.

A `kyb` session can only be minted while the status is `not_submitted`. Once the application is submitted, the request fails with 409 `KYB_ALREADY_SUBMITTED`, so check `kybStatus` first instead of catching the error.

When the customer returns to your `redirectUrl`, read `GET /team` again. In production the status is now `in_review`, and it becomes `verified` after Talentir's review. In the sandbox, the screen offers a one-click **Complete instantly** button, and the status is `verified` on return.

You can already [create payouts](#5-create-the-first-payout) while verification is in review. Only approval, `send-claim-link`, and `pay` are blocked; they fail with 422 `VERIFICATION_REQUIRED` until `missing` is empty, and `details.missing` says what is left.

## 4. Fund the balance [#4-fund-the-balance]

Payouts are approved against the team's balance, so the customer must wire money in before the first approval. `GET /team/accounts` returns one account per settlement currency, with its balance and, once verification is complete, the bank details to fund it:

```bash
curl "$BASE_URL/team/accounts" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

```json
{
  "object": "list",
  "data": [
    {
      "object": "account",
      "currency": "EUR",
      "payoutsEnabled": true,
      "balance": { "total": "0.00", "reserved": "0.00", "available": "0.00" },
      "allowance": null,
      "depositInstructions": [
        {
          "type": "bank_iban",
          "ownership": "dedicated",
          "beneficiary": "Customer GmbH",
          "iban": "CH93 0076 2011 6238 5295 7",
          "bic": "KLARCH22XXX",
          "bankName": "Bivial AG",
          "bankAddress": "Industriestrasse 24, 6300 Zug, Switzerland",
          "reference": null
        }
      ]
    }
  ]
}
```

Show the customer a "Fund your account" page built from this response:

* Pick the account whose `currency` the customer wants to fund. Transfers must be sent in that currency.
* `depositInstructions` is ordered by preference; show the first entry, and offer the others (for example `bank_swift` for a transfer from outside SEPA) as alternatives. `type` uses the payment method type names. The fields differ by `type`: `iban` and `bic` for `bank_iban`, `sortCode` and `accountNumber` for `bank_uk`, `routingNumber` and `accountNumber` for `bank_ach` and `bank_wire`.
* `type` is an open enum. Skip an instruction whose `type` you do not know.
* Always show `beneficiary` as the recipient name of the transfer.
* When `reference` is not `null`, the transfer must carry it as the payment reference. Without it the money cannot be attributed to the team. It is `null` when the account itself belongs to the team (`ownership` is `dedicated`).
* `depositInstructions` is empty until `kybStatus` is `verified`. Send the customer back to [verification](#3-get-the-team-verified) in that case.
* The first deposit completes the verification, so it must come from a bank account in the team's registered name. A first deposit from another account is returned. After the account is activated, deposits can come from any bank account.

There is no webhook for an incoming deposit. Poll `GET /team` and `GET /team/accounts`, and unlock the next step when `missing` is empty and `balance.available` covers the payouts you plan to create. The first deposit removes `first_deposit` from `missing`. `total` is everything the account holds, `reserved` is the part of the approved but unpaid payouts that this account is expected to pay, and `available` is `total` minus `reserved`: what a new approval can draw on.

A team can pay out from several currencies. `payoutsEnabled` marks their accounts in `GET /team/accounts`. A payout takes from the account of its own currency first if that account has `payoutsEnabled`, then from the account of `defaultPayoutCurrency`, then from the other accounts with `payoutsEnabled`. So `reserved` can sit on several accounts. What no account covers stays on the first account of that order, and its `available` turns negative. Approval counts the balances of all these accounts together, but one account pays each payout. So approval refuses a payout that is larger than every single account, with `INSUFFICIENT_BALANCE`. An approved payout can still wait for a top-up when other payouts used the account that it needs.

In the sandbox, no bank transfer is needed: the customer opens **Wallet** in the sandbox dashboard and clicks **Deposit 1,000 EUR**. The balance updates immediately.

## 5. Create the first payout [#5-create-the-first-payout]

Create payouts with the team's token exactly as for your own team. The simplest form needs only the recipient's email:

```bash
curl -X POST "$BASE_URL/payouts" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{
    "customId": "invoice-1001",
    "description": "Payment for September campaign",
    "amount": "100.00",
    "currency": "EUR",
    "recipient": { "type": "email", "email": "jane@example.com" }
  }'
```

The response is the payout in status `created`. Nothing moves yet: the payout waits for the customer's approval. Use `customId` to carry your own reference, so `{id}` in every later call can be your key instead of the `payout_…` id.

Every payout field, the batch form, and the sandbox failure scenarios are in [Pay with a payout link](/docs/v2/guides/pay-with-payout-link). To push money to a saved bank account, PayPal, or crypto wallet instead, create the counterparty in the customer's book and follow [Pay directly](/docs/v2/guides/pay-directly).

## 6. Let the customer approve [#6-let-the-customer-approve]

An OAuth client typically cannot approve through the API. Instead, your product shows the payouts that are ready and sends a team member to a hosted approval screen. Before you do, check the two preconditions yourself, so the customer does not hit a wall on the screen: `missing` on `GET /team` is empty, and `balance.available` covers the sum of the payouts.

```bash
curl -X POST "$BASE_URL/sessions" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "payout_approval",
    "payoutIds": ["payout_0e4ba886-0bfe-4b6a-ae2e-d6d9d135dd1e"],
    "branding": "platform",
    "redirectUrl": "https://yourapp.com/payouts"
  }'
```

The member reviews and approves with their passkey, then returns to your `redirectUrl`. Approval moves the payout to `approved` and reserves the amount; you see it as `payout.approved` on the webhook or as `status` on `GET /payouts/{id}`. See [Approve payouts](/docs/v2/guides/approve-payouts) for the other options and the errors approval can return.

If Talentir has granted your client `payouts:approve`, approval runs without a human in the loop, and the team owner must first set a daily spending allowance. Mint that screen with `type: "allowance"`. With that scope, `andThen: "approve_and_send_claim_link"` on create does steps 5 to 7 in one request.

## 7. Send the payout [#7-send-the-payout]

An `approved` payout waits for you to send it. For a payout to an email address, let Talentir email the claim link:

```bash
curl -X POST "$BASE_URL/payouts/invoice-1001/send-claim-link" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)"
```

The recipient signs in with a one-time code, chooses a payout method, and claims. The claim moves the payout to `requested`, and `completed` follows when the transfer settles; in the sandbox that happens instantly. To send the link from your own product instead, create the payout with `recipientNotifications: "disabled"` and mint the link with `type: "payout_claim"`; add `branding: "platform"` to show your brand on the claim screen. For a payout to a counterparty, call `POST /payouts/{id}/pay`.

## 8. Receive events [#8-receive-events]

Webhooks are per team. Register one for each connected team with its token, and verify the signature with that team's secret. A webhook stops when the customer revokes your app:

```bash
curl -X POST "$BASE_URL/webhooks" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://yourapp.com/talentir", "events": ["payout.*", "counterparty.*"] }'
```

Every event carries `teamId`, the id of the team it belongs to, so one URL can serve every connected team. Events cover payouts and counterparties only. Verification and deposits have no events; poll `GET /team` and `GET /team/accounts` for those. See [Set up webhooks](/docs/v2/guides/webhooks) for the payload and signature.

## Hosted sessions [#hosted-sessions]

`POST /sessions` mints a signed, stateless Talentir URL you send a user to. No iframe or UI work on your side. The endpoint validates the resource for the authenticated team and returns a session with a `url`.

Sessions are stateless: nothing is stored and there is no `GET`. Treat the returned `url` as opaque; redirect the user to it as is, and do not parse or build it. The URL confers no standing access, since every screen authenticates the visitor and gates the action, so a link is safe to email or hand to its intended recipient.

### Types and audiences [#types-and-audiences]

| `type`            | Audience                                     | Purpose                                                                             | Extra fields                                     |
| ----------------- | -------------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------ |
| `payout_claim`    | The payout **recipient** (not a team member) | Sign up or sign in and claim a payout                                               | `payoutId`                                       |
| `payout_approval` | A **member** of the authenticated team       | Review and approve `created` payouts                                                | `payoutIds` (at least one), optional `loginHint` |
| `kyb`             | A team member                                | Complete business verification                                                      | optional `loginHint`                             |
| `allowance`       | The team **owner**                           | Set the daily spending allowance of each payout currency, and add payout currencies | optional `loginHint`                             |

A `payout_approval` session checks every payout before it mints the URL, with the codes of `POST /payouts/approve`: an unknown payout fails with 404 `PAYOUT_NOT_FOUND`, and a payout that is not `created` fails with 409 `PAYOUT_NOT_APPROVABLE`. `error.index` is the position of that payout in `payoutIds`.

Team links (`payout_approval`, `kyb`, `allowance`) are for members of the team the token authorizes. The screen requires the visitor to sign in and be a member of that team. The sign-in code is pre-sent to the session creator; the optional `loginHint` field overrides who receives it.

The recipient link (`payout_claim`) is for the person being paid, who is not a member of the team. The screen shows the payout and lets the recipient sign in or sign up with their own email and claim it.

Do not send a team link to a recipient or vice versa.

It is safe to send a team link to someone who is not yet a member: after signing in they see a "Request access" screen. Requesting access emails the team's owner(s) with the requester's name and email and a one-click link to invite them. Access is never granted automatically. Once invited and accepted, the same session link works.

The team-member flows all operate on the team's wallet, a passkey wallet held by the team owner. If the team has not set one up yet, the hosted screen first prompts the owner to create it and then continues to the requested flow. Only the owner can complete this step. The recipient claim flow uses the recipient's own wallet and is unaffected.

### Common fields [#common-fields]

| Field         | Required | Effect                                                                                                                                                                            |
| ------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`        | yes      | The screen, see above.                                                                                                                                                            |
| `redirectUrl` | yes      | Where the user is returned after completing or leaving the screen, and the target of the screen's "Go Back" control. Must be an absolute `https` URL (`http` only for localhost). |
| `branding`    | no       | `talentir` (default), `team`, or `platform`. Selects the host and logo for the hosted flow.                                                                                       |

All session types require the `sessions:write` scope, which is granted by default.

### White-label branding [#white-label-branding]

| `branding`           | Hosted on                                                                                | Requires                                                                                                  |
| -------------------- | ---------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `talentir` (default) | The main Talentir host                                                                   | —                                                                                                         |
| `team`               | The **authenticated team's** white-label subdomain (`{slug}.talentir.com`) with its logo | The team's white-label feature and a configured subdomain                                                 |
| `platform`           | The subdomain and logo of the **team operating your OAuth client**                       | An OAuth-authenticated call with a dashboard-created client, and the operating team's white-label feature |

`platform` is the mode for platforms: the session operates on the connected customer team, while the page chrome carries your brand. It is rejected for API-key calls with 403 `BRANDING_UNAVAILABLE`, since an API key has no operating platform. The same code refuses `team` or `platform` when the branded team has no white-label feature or subdomain.

Sandbox and preview hosts do not provide per-team subdomains. On these hosts, Talentir keeps the session on the current host and carries the verified brand in a signed session token.

### Response [#response]

```json
{
  "object": "session",
  "id": "session_…",
  "type": "payout_claim",
  "url": "https://www.talentir.com/…",
  "createdAt": "2026-09-24T10:00:00.000Z"
}
```

The URL does not expire.


# Set up webhooks (/docs/v2/guides/webhooks)



Register a webhook to stay informed about payout, counterparty, and transaction changes instead of polling.

v2 webhooks follow [Standard Webhooks](https://www.standardwebhooks.com). An official Standard Webhooks library verifies them for you.

## Manage subscriptions [#manage-subscriptions]

* `POST /webhooks` — subscribe a `url` to `events`. The response carries the signing `secret` once.
* `GET /webhooks`, `GET /webhooks/{id}` — list and read your subscriptions (`secret` is `null` here).
* `PATCH /webhooks/{id}` — change `url`, `events`, or `enabled`.
* `DELETE /webhooks/{id}` — remove one.
* `POST /webhooks/{id}/rotate-secret` — replace the secret; the response carries the new one once. See [Rotate the secret](#rotate-the-secret).
* `POST /webhooks/{id}/test` — send a `webhook.ping` event, with the webhook as `data`, and return its delivery.
* `GET /webhooks/{id}/deliveries` — recent delivery attempts, newest first, with `status`, `responseStatus`, `attempt`, and `nextRetryAt`.

```bash
curl -X POST "$BASE_URL/webhooks" \
  -H "Authorization: Bearer $TALENTIR_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/talentir", "events": ["payout.*", "counterparty.*", "transaction.*"] }'
```

`url` must be a public `https` URL; private and reserved hosts are refused. `events` takes event types or a resource with the `*` wildcard, and needs at least one entry. An event carries its resource in full, so payout events need `payouts:read`, counterparty events need `counterparties:read`, and transaction events need `transactions:read`, on create and on every `PATCH`. Without the scope, the request fails with 403 `MISSING_SCOPE`.

Webhooks live in the environment and API version they were created in: a webhook registered via the sandbox API only fires for sandbox events, and one registered through v2 receives v2 bodies. A v1 subscription keeps receiving v1 bodies and never sees v2-only events.

A webhook belongs to the API key or OAuth grant that created or last updated it, and receives an event only while that credential can read it. If the key is deleted or disabled, loses the read scope, or the grant is revoked, the webhook stops receiving events. To rotate an API key, send `PATCH /webhooks/{id}` with the new key for each webhook, then delete the old key.

## Event types [#event-types]

| Type                   | When                                                                                                                                                                                                                           |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `payout.created`       | `POST /payouts`, or a dashboard create                                                                                                                                                                                         |
| `payout.updated`       | `PATCH /payouts/{id}`, or a counterparty change moved the payout back to `created`                                                                                                                                             |
| `payout.approved`      | Approval of a payout in `created`. A payout created with `andThen` sends only `payout.created`, with `data.status` already `approved` or later.                                                                                |
| `payout.requested`     | The recipient's claim, or `POST /payouts/{id}/pay`, also on a `failed` payout. Also when the provider returns a payout to a recipient with an account, and when that recipient starts a retry.                                 |
| `payout.completed`     | The transfer settled                                                                                                                                                                                                           |
| `payout.failed`        | The provider rejected the transfer; `data.failureCode` and `data.failureReason` say why                                                                                                                                        |
| `payout.deleted`       | Delete                                                                                                                                                                                                                         |
| `payout.expired`       | Expiry                                                                                                                                                                                                                         |
| `counterparty.created` | `POST /counterparties`, or a recipient claimed and a linked counterparty appeared                                                                                                                                              |
| `counterparty.updated` | `PATCH /counterparties/{id}`, or the recipient changed their details                                                                                                                                                           |
| `counterparty.deleted` | Delete                                                                                                                                                                                                                         |
| `transaction.created`  | The on-chain transfer behind the transaction is final and the balance has moved. Sent once, with `status: "completed"`; a `pending` or `failed` transaction sends nothing. See [Transactions](/docs/v2/concepts/transactions). |
| `webhook.ping`         | `POST /webhooks/{id}/test`. The webhook is `data`.                                                                                                                                                                             |

The list is open: new types appear without notice. Subscribe with wildcards and ignore types you do not handle.

## Event payload [#event-payload]

Each delivery is an HTTP `POST` with an event envelope. `data` is the resource as `GET` returns it when Talentir records the event, shortly after the change, with every field present and `null` where empty. It can already show a later step of the same request: the `payout.created` event of `andThen: "approve_and_pay"` has `status: "requested"`. A `*.deleted` event carries what `DELETE` returns, for example `{ "object": "payout", "id": "payout_…", "deleted": true }`: the resource is gone from the API. `timestamp` is when Talentir recorded the event.

```json
{
  "object": "event",
  "id": "event_9c1f…",
  "type": "payout.completed",
  "teamId": "team_4399dd07-cf1b-414b-bfbc-91a88dd73ec5",
  "timestamp": "2026-09-24T10:00:00.000Z",
  "apiVersion": "v2",
  "data": {
    "object": "payout",
    "id": "payout_0e4ba886-0bfe-4b6a-ae2e-d6d9d135dd1e",
    "status": "completed",
    "…": "…"
  }
}
```

Deduplicate on the event `id`: a retry sends the same event again with the same `id`. Events can arrive out of order; use `timestamp` or the resource's `status` to decide, not the arrival order.

## Delivery behavior [#delivery-behavior]

**Method**: POST, `Content-Type: application/json`.

**Request headers**:

* `webhook-id`: the event `id`. It is the same on every retry, so deduplicate on it.
* `webhook-timestamp`: Unix seconds of this attempt.
* `webhook-signature`: a space-separated list of signatures, each `v1,` followed by a base64 signature. See [Verify signatures](#verify-signatures).

v2 webhooks do not send the `X-Talentir-*` headers of v1. A v1 webhook keeps its `X-Talentir-Signature` scheme; see [Migrate from v1 to v2](/docs/v2/guides/migrate-to-v2#signature).

**Outcome of an attempt**: each attempt times out after 15 seconds.

* **Success (2xx)**: delivery confirmed.
* **Gone (410)**: not retried. The subscription is disabled at once (`enabled: false`).
* **Redirect (3xx)**: not followed. It counts as a failure. Update the `url` instead.
* **Every other answer, a timeout, or a network error**: a failure. It is retried.

**Retry schedule**: a failed attempt is retried on the Standard Webhooks schedule. The waits before the retries are 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 14 hours, 20 hours, and 24 hours, each with up to 10% random delay added. That is 10 attempts over about three days. `nextRetryAt` on the delivery says when the next attempt is due.

**Disabling**: when 25 events in a row fail on every attempt, and these failures span at least 5 days, the subscription is disabled (`enabled: false`). Fix the endpoint, check `GET /webhooks/{id}/deliveries`, then send `PATCH /webhooks/{id}` with `{ "enabled": true }`. This resets the count. Every member of your team gets an email when we disable a subscription, for a 410 or for failed events.

`POST /webhooks/{id}/test` sends one attempt. It is not retried and does not count toward disabling.

A disabled subscription (`enabled: false`) receives nothing until it is enabled again.

## Verify signatures [#verify-signatures]

Every webhook request includes a cryptographic signature. **Always verify webhook signatures** to ensure requests are genuinely from Talentir.

The secret is `whsec_` followed by a base64 key. `POST /webhooks` and `POST /webhooks/{id}/rotate-secret` return it once. The signature is the HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded part of the secret after `whsec_`. It is encoded as base64 and has the prefix `v1,`.

### With a library [#with-a-library]

Use an official [Standard Webhooks](https://www.standardwebhooks.com) library. Libraries exist for many languages. For Node:

```bash
npm install standardwebhooks
```

```ts
import { Webhook } from "standardwebhooks";

const webhook = new Webhook(process.env.TALENTIR_WEBHOOK_SECRET); // "whsec_…"

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  try {
    // Throws when no signature matches or the timestamp is more than 5 minutes off.
    const event = webhook.verify(req.body.toString("utf8"), req.headers);
    // Deduplicate on event.id, then handle event.type and event.data.
    res.status(200).end();
  } catch {
    res.status(400).send("Invalid signature");
  }
});
```

### Without a library [#without-a-library]

1. Read `webhook-id`, `webhook-timestamp`, and `webhook-signature` from the request headers.
2. Reject the request when `webhook-timestamp` is more than 5 minutes away from now. This prevents replay attacks.
3. Decode the part of the secret after `whsec_` from base64. This is the key.
4. Compute the HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{raw body}` with the key. Encode it as base64 and put `v1,` in front.
5. Compare the result with every space-separated entry of `webhook-signature`, using timing-safe comparison. The request is valid when any entry matches.

Verify the raw body, not a re-serialized one.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

function verifyWebhook(
  rawBody: string,
  id: string,
  timestamp: string,
  signatureHeader: string,
  secret: string
): boolean {
  const now = Math.floor(Date.now() / 1000);
  const sentAt = Number(timestamp);
  if (!Number.isInteger(sentAt) || Math.abs(now - sentAt) > 300) return false;

  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const hmac = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest("base64");
  const expected = Buffer.from(`v1,${hmac}`);

  return signatureHeader.split(" ").some((entry) => {
    const candidate = Buffer.from(entry);
    return candidate.length === expected.length && timingSafeEqual(candidate, expected);
  });
}
```

## Rotate the secret [#rotate-the-secret]

After `POST /webhooks/{id}/rotate-secret`, each delivery carries two signatures for 24 hours: one with the new secret and one with the old. Deploy the new secret within those 24 hours. A request is valid when any signature matches, so no event is dropped. After 24 hours, only the new secret signs.


# Create (/docs/v1/reference/payouts/payout.create)

Create or update a new payout for team members.

For teams with balance enforcement enabled (the default), pre-approved payouts (`preApproved: true`) are rejected with HTTP 422 and error code `INSUFFICIENT_BALANCE` when the total of all open (approved but not yet paid out) payouts would exceed the team wallet balance. The error `data` contains `walletBalance`, `totalOpenAmount`, and `currency`.

# Get (/docs/v1/reference/payouts/payout.get)

Retrieve a specific payout by ID or custom ID.

By default, the endpoint expects an ID. To query by custom ID instead, add the query parameter `id_type=custom_id`.

Examples:
- Get by ID: `GET /payout/{id}`
- Get by custom ID: `GET /payout/{custom_id}?id_type=custom_id`

# Delete (/docs/v1/reference/payouts/payout.delete)

Delete a payout that has not been requested yet.

Only payouts with status `created`, `approved` or `expired` can be deleted. Once the recipient has requested the payout (status `requested`) or it is `completed`, the request fails with HTTP 409. Deleting an already deleted payout succeeds and returns it unchanged. A deleted payout emits a webhook event with status `deleted`.

By default, the endpoint expects an ID. To delete by custom ID instead, add the query parameter `id_type=custom_id`.

Examples:
- Delete by ID: `DELETE /payout/{id}`
- Delete by custom ID: `DELETE /payout/{custom_id}?id_type=custom_id`

# List (/docs/v1/reference/payouts/payout.list)

Retrieve a list of payouts for the authenticated team.

# Payout (/docs/v1/reference/sessions/session.payout)

Audience: the payout RECIPIENT — the creator/rightsholder being paid, who is not a member of the authenticated team. Validate a payout for the authenticated team and return a Talentir URL to send to that recipient: they sign in or sign up with their own email (inferred from the payout) and claim the payout. No team membership is required.

# Approval (/docs/v1/reference/sessions/session.approval)

Audience: a MEMBER OF THE AUTHENTICATED TEAM (the team the token authorizes — with OAuth this can be a customer's team, not your own). Validate pending payouts for that team and return a Talentir URL where a team member reviews and approves them. The screen requires the visitor to sign in and be a member of that team; the sign-in code is pre-sent to the session creator's email.

# Kyb (/docs/v1/reference/sessions/session.kyb)

Audience: a MEMBER OF THE AUTHENTICATED TEAM (the team the token authorizes — with OAuth this can be a customer's team, not your own). Validate the authenticated team and return a Talentir URL where a team member completes KYB (Know Your Business) verification. The screen requires the visitor to sign in and be a member of that team; the sign-in code is pre-sent to the session creator's email.

# Allowance (/docs/v1/reference/sessions/session.allowance)

Audience: the OWNER of the authenticated team (the team the token authorizes — with OAuth this can be a customer's team, not your own). Returns a Talentir URL where the team owner sets or edits the daily spending allowance for automatic payouts. The screen requires the visitor to sign in and be the team owner; the sign-in code is pre-sent to the session creator's email.

# Get (/docs/v1/reference/team-management/team.get)

Retrieve information for the authenticated team: name, KYB status, total converted wallet balance, balances, bank deposit instructions per currency, and the team's members with their roles (owners first). The per-balance 'depositInfo' field is deprecated in favor of the top-level 'deposits' array and will be removed on 2026-09-23.

# List (/docs/v1/reference/webhooks/webhook.list)

List all webhooks for your team. Webhooks are per environment: a webhook registered on the sandbox only fires for sandbox events, one registered on production only fires for production events.

# Create (/docs/v1/reference/webhooks/webhook.create)

Create a new webhook to receive event notifications. Webhooks live in the environment they were created in: register on sandbox.talentir.com to receive sandbox events, and on www.talentir.com to receive production events.

# Delete (/docs/v1/reference/webhooks/webhook.delete)

Delete a webhook

# List payouts (/docs/v2/reference/payouts/payouts.list)

Newest first by `createdAt`, `id` as tie-breaker. Needs `payouts:read`.

# Create a payout (/docs/v2/reference/payouts/payouts.create)

Creates a payout in status `created`. `andThen` runs approval, and then `send-claim-link` or `pay`, in the same request and fails with their codes. Needs `payouts:write`, plus `payouts:approve` when `andThen` is set.

# Create payouts in one request (/docs/v2/reference/payouts/payouts.createBatch)

Creates up to 100 payouts. Each item is a `POST /payouts` body, `andThen` included. All or nothing: one failing item fails the request with that item's code and `error.index`, and nothing is applied. Needs `payouts:write`, plus `payouts:approve` when any item sets `andThen`.

# Get a payout (/docs/v2/reference/payouts/payouts.get)

`{id}` is the payout id or your `customId`. Needs `payouts:read`.

# Update a payout (/docs/v2/reference/payouts/payouts.update)

Allowed while `status` is `created`. An omitted field is unchanged; `recipient` is replaced as a whole. Needs `payouts:write`.

# Delete a payout (/docs/v2/reference/payouts/payouts.delete)

Allowed while `status` is `created`, `approved`, or `expired`. A `failed` payout cannot be deleted: its money waits for `pay`. A deleted payout is gone from the API: every endpoint answers 404 `PAYOUT_NOT_FOUND` for it, and lists do not include it. Its `customId` stays taken. Needs `payouts:write`.

# Approve a payout (/docs/v2/reference/payouts/payouts.approve)

Moves `created` to `approved` and reserves the amount against the team balance. The body is optional. Needs `payouts:approve`, plus `payouts:write` with `andThen: "pay"`.

# Send the claim link (/docs/v2/reference/payouts/payouts.sendClaimLink)

Talentir emails the claim link to the recipient and sets `recipientNotifications` to `enabled`. One reminder follows 7 days after the latest claim-link email; calling it again resends the email and moves a reminder that has not gone out yet. A locked payout with `recipientNotifications: "disabled"` fails with `PAYOUT_LOCKED`. Needs `payouts:write`.

# Pay a counterparty directly (/docs/v2/reference/payouts/payouts.pay)

Sends the money to the counterparty's saved payment method. Settlement is asynchronous; `payout.completed` or `payout.failed` follows. A `failed` payout is paid again with the same call. Needs `payouts:write`, plus `payouts:approve` for a `failed` payout: it goes to the counterparty's current payment method, which the approval did not cover.

# Approve payouts in one request (/docs/v2/reference/payouts/payouts.approveBatch)

Approves up to 100 payouts and reserves their sum against the team balance in one decision. `andThen` runs `send-claim-link` or `pay` for every payout. All or nothing: one failing item fails the request with that item's code and `error.index`, and nothing is applied. Needs `payouts:approve`, plus `payouts:write` with `andThen: "pay"`.

# Send claim links in one request (/docs/v2/reference/payouts/payouts.sendClaimLinkBatch)

Emails the claim link for up to 100 payouts. Every payout is checked before the first email goes out. All or nothing: one failing item fails the request with that item's code and `error.index`, and nothing is applied. Needs `payouts:write`.

# Pay counterparties in one request (/docs/v2/reference/payouts/payouts.payBatch)

Requests the transfer for up to 100 counterparty payouts. Every payout moves to `requested` in one transaction; settlement follows per payout as `payout.completed` or `payout.failed`. All or nothing: one failing item fails the request with that item's code and `error.index`, and nothing is applied. Needs `payouts:write`, plus `payouts:approve` when any payout is `failed`.

# List counterparties (/docs/v2/reference/counterparties/counterparties.list)

Newest first by `createdAt`, `id` as tie-breaker. A recipient-managed row that a team-managed row overrides (same email) is not listed. Needs `counterparties:read`.

# Create a counterparty (/docs/v2/reference/counterparties/counterparties.create)

`taxIdentity` is checked against the rules of [`GET /countries`](https://www.talentir.com/docs/v2/reference/countries/countries.list) for the address country and the identity type. Without `paymentMethod` and `taxIdentity` the counterparty exists, and `missing` names what a direct payment still needs. Needs `counterparties:write`.

> **Warning:** Talentir can change the tax rules, the settlement currencies, and the payout assets at any time, without prior notice. Do not hardcode them. Read the tax fields from [`GET /countries`](https://www.talentir.com/docs/v2/reference/countries/countries.list) and the currencies and assets from [`GET /payment-method-types`](https://www.talentir.com/docs/v2/reference/payment-method-types/payment-method-types.list) when you build the form.

# Get a counterparty (/docs/v2/reference/counterparties/counterparties.get)

`{id}` is the counterparty id or your `customId`. Needs `counterparties:read`.

# Update a counterparty (/docs/v2/reference/counterparties/counterparties.update)

An omitted field is unchanged; a nested object is replaced as a whole and `null` clears it. A change to the address, payment method, or tax data moves every `approved` payout to this counterparty back to `created`. Needs `counterparties:write`.

# Delete a counterparty (/docs/v2/reference/counterparties/counterparties.delete)

Refused while a payout to it is `created`, `approved`, `requested`, or `failed`. A deleted counterparty is gone from the API: every endpoint answers 404 `COUNTERPARTY_NOT_FOUND` for it, and its `customId` is free again. Payouts to it keep its id in `recipient.counterpartyId`. Needs `counterparties:write`.

# List countries (/docs/v2/reference/countries/countries.list)

Every country a counterparty can live in, with the tax data it needs. Ordered by `id`. Never paged. Needs `counterparties:read` or `counterparties:write`.

# List payment method types (/docs/v2/reference/payment-method-types/payment-method-types.list)

Every payment method type a counterparty can save, with the currencies or assets it settles in right now. Currencies and assets may be added or removed without a new API version, so build your form from this list. Never paged. Needs `counterparties:read` or `counterparties:write`.

# List webhooks (/docs/v2/reference/webhooks/webhooks.list)

Only v2 subscriptions. Newest first. Needs `webhooks:read`.

# Create a webhook (/docs/v2/reference/webhooks/webhooks.create)

Subscribes a URL to events. The response carries the signing `secret` once. A subscription created here receives v2 bodies. Needs `webhooks:write`, plus `payouts:read` to receive payout events, `counterparties:read` to receive counterparty events, and `transactions:read` to receive transaction events. The webhook receives events only while the credential that created or last updated it can read them.

# Get a webhook (/docs/v2/reference/webhooks/webhooks.get)

Needs `webhooks:read`.

# Update a webhook (/docs/v2/reference/webhooks/webhooks.update)

An omitted field is unchanged. Needs `webhooks:write`, plus the read scopes of the events the webhook then receives: `payouts:read` for payout events, `counterparties:read` for counterparty events, `transactions:read` for transaction events. The caller's credential becomes the webhook's credential.

# Delete a webhook (/docs/v2/reference/webhooks/webhooks.delete)

Needs `webhooks:write`.

# Rotate the signing secret (/docs/v2/reference/webhooks/webhooks.rotateSecret)

Replaces the secret. The response carries the new `secret` once. For 24 hours, deliveries carry a signature with the old secret and one with the new, so you can deploy the new secret without dropping events. Needs `webhooks:write`.

# Send a test event (/docs/v2/reference/webhooks/webhooks.test)

Sends a `webhook.ping` event, with the webhook as `data`, and returns the delivery. Needs `webhooks:write`.

# List deliveries (/docs/v2/reference/webhooks/webhooks.listDeliveries)

Recent delivery attempts, newest first. Needs `webhooks:read`.

# Create a session (/docs/v2/reference/sessions/sessions.create)

Mints a signed Talentir URL for a hosted screen. `type` selects the screen and its audience. A session has no `GET`; the id is embedded in the URL. Needs `sessions:write`.

# Get the team (/docs/v2/reference/team/team.get)

Returns the authenticated team. Needs `team:read`.

# List accounts (/docs/v2/reference/team/team.listAccounts)

One account per settlement currency, with its balance and deposit instructions. Never paged. Needs `team:read`.

# List team members (/docs/v2/reference/team/team.listMembers)

Owners first, then by `joinedAt`. Needs `team:read`.

# Deposit test money (/docs/v2/reference/team/team.sandboxDeposit)

Sandbox only. Adds `amount` to the balance of the account, as a bank deposit would, and returns the account. Outside the sandbox the request fails with `SANDBOX_ONLY`. The team needs a wallet first; without one the request fails with `VERIFICATION_REQUIRED`. Needs no scope.

# List transactions (/docs/v2/reference/transactions/transactions.list)

Every movement on the team's accounts, newest first by `createdAt`, `id` as tie-breaker. Needs `transactions:read`.

# Get a transaction (/docs/v2/reference/transactions/transactions.get)

Needs `transactions:read`.