Migrate from v1 to v2
What changes between /api/v1 and /api/v2, and how to move an integration step by step.
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
| 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.
Test the migration in the sandbox first. It has its own teams, API keys, and webhooks.
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 /countriesreturns, for every country Talentir pays to, the tax fields a counterparty from that country needs, split intoindividualandorganization, 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-typesreturns every rail a counterparty can save, with the currencies a fiat rail settles in and the tokenscryptopays out. New currencies and assets appear there, not in a new API version. - An explicit payout cycle. Create, approve, then either
send-claim-linkorpay. Talentir emails the claim link only after you callsend-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.
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
cryptopayment method and pay directly. A recipient who claims can still choose crypto. - Upsert on
POST /payout. v2POST /payoutsonly creates. Updates arePATCH /payouts/{id}. id_type=custom_id. The{id}in a payout or counterparty path accepts either the prefixed id or yourcustomId.offset,order_by, andorder_directiononGET /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
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. 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 aspayout_…. Look these payouts up by their id. - must not start with the resource's prefix.
customId: "payout_…"is rejected with 400VALIDATION_FAILED. - is unique within your team and per resource. Payouts and counterparties have separate spaces.
- is refused on create and on
PATCHwhile another payout or counterparty holds it: 409CUSTOM_ID_TAKEN, anddetails.idnames the holder. - stays taken after its payout is deleted, so a repeated create can never pay twice.
details.idis thennull. To redo a deleted payout, use a newcustomId. - is free again after its counterparty is deleted.
- works wherever a payout or counterparty is referenced: the
{id}in a path, theidsof a batch request,recipient.counterpartyId, thepayoutIdandpayoutIdsof a session, and therecipientCounterpartyIdfilter. 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
- 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,targetUrlandeventType. amountis a decimal string with up to two decimals and a minimum of0.10, for example"100.00".descriptionhas 1 to 5000 characters. v1 had no maximum.tagsis an array of at most 20 tags with 1 to 100 characters each, or you do not send it.nullis rejected.- An email has at most 254 characters.
recipient.phoneis E.164 with no spaces, for example+4915112345678. - A calendar date such as
availableOnisYYYY-MM-DDand must be a real day.2026-02-30is rejected with 400VALIDATION_FAILED. - A
redirectUrlmust behttps(httponly forlocalhost) and has at most 2048 characters. v1 acceptedhttp. - Every create returns 201: payouts, payout batches, counterparties, webhooks, and sessions. v1 returned 200.
- Send
Idempotency-Keyon everyPOST. See Idempotency.
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. |
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 for index. |
| — | /counterparties, GET /countries, GET /payment-method-types | New. |
POST /session/payout | POST /sessions with type: "payout_claim" | See 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. |
GET /webhook | GET /webhooks | Lists v2 webhooks only. |
POST /webhook | POST /webhooks | See 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
| 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. |
preApproved: true | andThen: "approve" | Same status, approved, and no email. Needs payouts:approve. The error codes change; see Errors. The dashboard does not send a claim link for it; see 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. |
requestedAt | requestedAt | Also set by pay. |
paidOutAt | completedAt | Set only while status is completed. v1 can show paidOutAt earlier, on a bank transfer that is sent but not settled. |
toMethod | paymentMethod.type | See 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
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
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
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.
# 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.
andThenon create needspayouts:approve.approvewithandThen: "pay"also needspayouts:write.- The
payvariants need a counterparty recipient. On create,andThen: "approve_and_pay"without one fails with 400VALIDATION_FAILED. Onapprove,andThen: "pay"without one fails with 422RECIPIENT_NOT_COUNTERPARTY. - Before a future
availableOn,pay,send-claim-link, and everyandThenthat includes one of them fail with 409PAYOUT_NOT_YET_AVAILABLE. UseandThen: "approve", then callpayorsend-claim-linkon or afteravailableOn.
# 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
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:
-
Read the recipient's country from the country list:
GET /api/v2/countriesThe identity and address fields of a counterparty are fixed; the tax identity and the payment method are not. Each country has
taxRequirements.individualandtaxRequirements.organization. Each of these hasfields, an ordered list: each field has akey(thetaxIdentityfield name), alabel, help text inlocalName,format, anddescription(each ornull), apattern(ornull), and anexample(ornull). It also hasanyOf, the accepted combinations: send every field of any one set. An emptyanyOfmeans no tax data is required. The rules already reflect your team's DAC7 setting; you never evaluate DAC7 yourself. See Pay directly for a full example. -
Create the counterparty:
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" } }'identityandaddressare required.paymentMethod.currencymust be one of thecurrenciesthatGET /payment-method-typesgives for thattype. In the response,missingnames the gaps a push payment still has (payment_method,tax_identity,address). An emptymissingmeansPOST /payouts/{id}/paycan succeed.GET /counterparties?payable=truelists only the counterparties with an emptymissing. The list also filters bytype,country,paymentMethodType, andemail. -
Create payouts with
"recipient": { "type": "counterparty", "counterpartyId": "counterparty_…" }and pay them withPOST /payouts/{id}/pay, or send a claim link if the counterparty has anemail.payto a counterparty whosemissingis not empty fails with 422COUNTERPARTY_NOT_PAYABLE, anddetails.missinglists 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
| 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. |
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
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 of every currency, converted to the team's payout currency, on that currency's account only. The other accounts show "0.00" where v1 showed null. |
balances[].balanceAfterPendingPayouts | data[].balance.available | total minus reserved. |
balances[].isActivePayout | GET /team: defaultPayoutCurrency | 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
v1 GET /payouts returned a bare array and paged with offset and limit. v2 lists return an envelope:
{ "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
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.
{ "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
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.
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 defines it.
v2 records events only while you have an enabled v2 webhook. Changes before you register it are not sent.
Event body
A v1 body was the payout itself, in the v1 shape, with a message string. A v2 body is an event envelope:
{
"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. 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.updatedis sent when the description, amount, currency, tags,customId,availableOn, recipient, orlockedchanges, and when a payout goes back tocreated. v1 sent nothing for a change that kept the status.webhook.pingis sent byPOST /webhooks/{id}/testto the webhook, whatever itsevents, with the webhook asdata.- Deduplicate on the event
id, which is also thewebhook-idheader. A retry sends the same id. - Events can arrive out of order, and one request can send several events:
andThen: "approve_and_pay"sendspayout.createdandpayout.requested.datais the payout when Talentir records the event, so it can already show a later step: here, thepayout.createdevent hasstatus: "requested". For the current state, readGET /payouts/{id}; a deleted payout answers 404. payon afailedpayout sendspayout.requested, thenpayout.completedor anotherpayout.failed.
Signature
v2 webhooks follow Standard Webhooks. 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:
import { Webhook } from "standardwebhooks";
const event = new Webhook(secret).verify(rawBody, headers); // throws when the request is not validEach retry has a new webhook-timestamp and a new signature, so deduplicate on webhook-id, not on the signature. See Verify signatures for a check without a library and the timestamp age to accept, and 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
v1 did not retry a 4xx answer other than 429. It deleted a webhook when your endpoint answered 410, or after 25 other 4xx answers 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 deleting: at once on 410, or after 25 events in a row that fail every attempt. 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
| 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 |
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 /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 and counterparties:read for counterparty 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:* scope. Add it 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 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
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/approveand/payouts/payout_…/approveare different paths for the key. - The same key on the same path replays the stored response with
Idempotent-Replayed: truefor at least 24 hours. The same key with a different body fails with 409IDEMPOTENCY_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
- 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
currencyfield next to it. - Timestamps end in
At, except the eventtimestamp, and are ISO 8601 UTC with milliseconds. Calendar dates areYYYY-MM-DD. - Most resources have
object,id,createdAt, andupdatedAt. A session hascreatedAtonly. A team member has noidand onlyjoinedAt. An account has noidand no timestamps. Countries and payment method types have no timestamps. An event hastimestamp. - Top-level fields of a resource are never omitted; they are
null. Arrays are nevernull, exceptcurrenciesandassetsinGET /payment-method-types:currenciesisnullforcrypto, andassetsisnullfor 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,currencyon read,missing,kybStatus,role,depositInstructions[].type, and eventtype. On write, a payoutcurrencyis exactlyUSD,EUR,CHF, orGBP. A counterparty'spaymentMethod.currencyis one of thecurrenciesof its type. See Changes without a new version for what can change.
Checklist
- Switch the base URL to
/api/v2. Test in the sandbox first. - Add
counterparties:*to your API key. OAuth clients authorize again, withresource=<base_url>/api/v1while they still call v1. Ask Talentir forpayouts:approveif you approve through the API. - Prefix stored ids:
payout_,counterparty_,team_. - Rename fields per the field map. Send
amount, notpayoutAmount. Sendlocked, notpayoutType. SendandThen: "approve", notpreApproved. - Replace
verificationMethod,email,phone, andcounterpartyIdwith therecipientobject. Stop creating handle and wallet payouts. - Remove every v1-only field from request bodies. v2 rejects unknown fields. Check the new limits in Requests.
- Replace update-by-
POSTwithPATCH /payouts/{id}. It works only whilestatusiscreatedand changes only the fields you send. - Add the step after approval:
send-claim-link,pay, or your own link fromPOST /sessions. - Map
status,paymentMethod.type, andkybStatusvalues. Handle the new statusfailed. Treat open enums as open. - Expect 201 on every create, and
nullinstead of omitted fields. - Replace
offsetpaging withcursor. v2 ignoresoffset. Readdatafrom the list envelope. - Read
error.codefrom the new error shape. Move validation handling from 422 to 400. - Move sessions to
POST /sessionswithtype. Send prefixed payout ids or yourcustomIds. - Read balances from
GET /team/accountsand members fromGET /team/members. - Register a v2 webhook with
events[]. Store itssecret. Verify each request with a Standard Webhooks library. Readtypeanddatafrom the envelope. Deduplicate onid. Answer 2xx to event types you do not handle. After an API key rotation,PATCHthe webhook with the new key. - Send
Idempotency-Keyon everyPOST. Retry a network failure with the same key. After a 500, check the state before you send a new key. - 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.