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. Only the shape of the JSON differs. 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>. v2 accepts an OAuth token that is bound to /api/v1; see OAuth. The OpenAPI document is at /api/v2/spec.json.
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 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, 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 recipient 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, as read-only.
- 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.- The
notificationsfield. See The payout cycle. order_byandorder_directiononGET /payouts. v2 lists are always newest first.- Deprecated v1 fields:
uuid,url,whitelabel,teamUuid,depositInfo.
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. A v1 webhook id does not work in v2; see Webhooks.
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 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 keeps working in every lookup and is returned as it is. - 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 while another payout or counterparty holds it: 409
CUSTOM_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. 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. amountis a decimal string with up to two decimals and a minimum of0.10, for example"100.00".recipient.phoneis E.164 with no spaces, for example+4915112345678.tagsis an array, or you do not send it.nullis rejected.- 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. v1 also updates approved and expired payouts. A locked payout fails with 409 PAYOUT_LOCKED. |
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. |
| — | 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, and the error has 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. |
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 | Read-only. v2 cannot create these. |
verificationMethod: "wallet_address", walletAddress | recipient: { type: "wallet", walletAddress } | Read-only. v2 cannot create these. |
handleType | recipient.type | |
email, phoneNumber on a counterparty, handle, or wallet payout | — | Not in the payout. 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. |
notifications | — | Gone. claimLinkSentAt shows when Talentir last emailed the claim link. |
preApproved: true | andThen: "approve" | Same result: status approved, no email. Needs payouts:approve. The error codes change; see Errors. |
| — | 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 | |
toMethod | paymentMethod.type | See Payment method values. paymentMethod is null until requested. |
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 (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, when the provider returned the transfer. v1 showed that payout as completed. failureCode and failureReason say why. Fix the counterparty, then call pay again; the payout goes back to requested. A returned transfer to a recipient who claimed stays completed in both versions, and the recipient claims again.
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. After that call, Talentir sends one reminder after 7 days, and one email when a future availableOn arrives.
send-claim-link needs status approved, an availableOn that is today or earlier, and a recipient email. For a counterparty recipient, that is the counterparty's email; without one, the call fails with 422 RECIPIENT_EMAIL_MISSING.
A payout that v2 creates gets no claim link from the dashboard until the API calls send-claim-link once. Payouts that v1 created keep their v1 notifications setting. If a team member approves v2 payouts in the dashboard, call send-claim-link when your webhook receives payout.approved.
# 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 does every step or none: a counterparty that cannot be paid fails the whole create with 422 COUNTERPARTY_NOT_PAYABLE and leaves no payout behind.
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. - A payout with a future
availableOncannot be claimed or paid before that date.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, 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. -
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 | Each id is payout_… or your customId. |
POST /session/kyb | POST /sessions with type: "kyb" | |
POST /session/allowance | POST /sessions with type: "allowance" | |
redirectUrl, branding, loginHint | redirectUrl, branding, loginHint | loginHint is not accepted on payout_claim. |
whitelabel | — | Rejected with 400. Use branding: "team", which needs the white-label feature. |
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. New: legalName, address, and missing, the steps left before the team can approve payouts. |
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 per currency. Not paged. |
balances[].openPayoutAmount | data[].balance.reserved | |
balances[].balanceAfterPendingPayouts | data[].balance.available | |
deposits[].rails[] | 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. An absent bank field is null. |
dac7ReportingRequired | — | GET /countries already applies it. |
totalBalance, totalBalanceCurrency, scheduledPayoutAmount, allowance, tokenSymbol, tokenAddress, walletAddress, isActivePayout, depositInfo | — | Gone. To set the allowance, use POST /sessions with type: "allowance". |
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. |
limit (1 to 100, default 50) | limit (1 to 100, default 20) |
order_by, order_direction | — Always newest first. |
| — | status (repeat for several), recipientCounterpartyId, tag (repeat; any match), createdFrom (inclusive), createdBefore (exclusive) |
Send the same filters with every cursor. A cursor with other filters fails with 400 VALIDATION_FAILED.
/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. A batch error also has index, the 0-based position of the item that failed.
{ "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 |
| — | NOT_FOUND, 404: no endpoint matches the method and path |
CONFLICT, update of a manual-immutable payout | PAYOUT_LOCKED, 409, details.status |
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 |
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 every payout change, also the changes made through the other version. v1 bodies keep the bare ids. The webhook url must be a public https URL; to test from your machine, use a tunnel.
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 the transition happened. 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 |
requested | payout.requested |
completed | payout.completed |
deleted | payout.deleted, with data { object, id, deleted: true } |
expired | payout.expired |
| — | payout.updated, payout.failed, counterparty.created, counterparty.updated, counterparty.deleted, webhook.ping |
payout.updatedis sent when a payout field changes.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.created,payout.approved, andpayout.requested.datais the payout at the time of the event. For the current state, readGET /payouts/{id}; a deleted payout answers 404. payout.failedis new; see Status values.
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 deleted a webhook when your endpoint answered 410, or after 25 other 4xx answers with no successful delivery in between. v2 sets enabled: false instead: at once on 410, or after 25 events in a row that fail every attempt. Fix the endpoint, then send PATCH /webhooks/{id} with { "enabled": true }.
Scopes
| v1 | v2 |
|---|---|
payouts:read, payouts:write, payouts:approve | unchanged |
| — | 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, pay | 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. |
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 with payout or counterparty events | also payouts:read or counterparties:read |
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. 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
Send Idempotency-Key on every POST. v1 ignores the header.
- 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. - A 4xx response is stored and replayed too, except
MISSING_SCOPEandUNAUTHORIZED. A 5xx response is not stored, so you can retry it with the same key.
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 or a 5xx with the same key. After a 4xx, fix the cause, for example add balance after INSUFFICIENT_BALANCE, and send a new key (payout-create:INV-42:2). 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 response fields are never omitted; they are
null. Arrays are nevernull. - 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,currencyis exactlyUSD,EUR,CHF, orGBP. See Changes without a new version for what can change.
Checklist
- Switch the base URL to
/api/v2. Test in the sandbox first. - 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.
- Replace update-by-
POSTwithPATCH /payouts/{id}. - Add the step after approval:
send-claim-link,pay, or your own link fromPOST /sessions. - Map
status,paymentMethod.type, andkybStatusvalues. Handle the new statusfailed. - Expect 201 on every create.
- Replace
offsetpaging withcursor. 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. - Add
counterparties:*to your key. Ask Talentir forpayouts:approveif you approve through the API. - Send
Idempotency-Keyon everyPOST. - Delete the v1 webhook with v1
DELETE /api/v1/webhook/{id}once the v2 webhook is verified.