API Docs
Guides

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

Environmentv1v2
Productionhttps://www.talentir.com/api/v1https://www.talentir.com/api/v2
Sandboxhttps://sandbox.talentir.com/api/v1https://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 /countries returns, for every country, 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 recipient 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.

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 crypto payment method and pay directly. 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.
  • The notifications field. See The payout cycle.
  • order_by and order_direction on GET /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:

v1v2
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 400 VALIDATION_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, 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. 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.
  • amount is a decimal string with up to two decimals and a minimum of 0.10, for example "100.00".
  • recipient.phone is E.164 with no spaces, for example +4915112345678.
  • tags is an array, or you do not send it. null is rejected.
  • Every create returns 201: payouts, payout batches, counterparties, webhooks, and sessions. v1 returned 200.
  • Send Idempotency-Key on every POST. See Idempotency.

Endpoint map

v1v2Notes
POST /payoutPOST /payoutsCreate only. preApproved is andThen: "approve".
POST /payout with idPATCH /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_idGET /payouts/{customId}No query parameter.
GET /payoutsGET /payoutsSee 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}/approveIn v1: preApproved, the dashboard, or an approval session.
—POST /payouts/{id}/send-claim-linkNew. Talentir emails the claim link.
—POST /payouts/{id}/payNew. Push payout to a counterparty.
—POST /payouts/batch, POST /payouts/approve, POST /payouts/send-claim-link, POST /payouts/payNew. 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-typesNew.
POST /session/payoutPOST /sessions with type: "payout_claim"See Sessions.
POST /session/approvalPOST /sessions with type: "payout_approval"
POST /session/kybPOST /sessions with type: "kyb"
POST /session/allowancePOST /sessions with type: "allowance"
GET /teamGET /team, GET /team/members, GET /team/accountsSee Team.
GET /webhookGET /webhooksLists v2 webhooks only.
POST /webhookPOST /webhooksSee 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, deliveriesNew.

Payout field map

v1v2Notes
id, uuididPrefixed. uuid is gone.
—objectAlways "payout".
payoutAmount (request), amount (response)amountSame name everywhere. Decimal string. Responses always have two decimals.
currencycurrency
descriptiondescription
customIdcustomId
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", counterpartyIdrecipient: { 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 creatorHandlerecipient: { type, handle } with type of tiktok, instagram, youtube_channelRead-only. v2 cannot create these.
verificationMethod: "wallet_address", walletAddressrecipient: { type: "wallet", walletAddress }Read-only. v2 cannot create these.
handleTyperecipient.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: falseThe default.
payoutType: "manual-immutable"locked: true
payoutType of advance, affiliate, referral_reward (response only)locked: falsev2 does not show the type.
notifications—Gone. claimLinkSentAt shows when Talentir last emailed the claim link.
preApproved: trueandThen: "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.
availableOnavailableOn
tagstagsNever null.
statusstatusSee Status values.
requestedAtrequestedAtAlso set by pay.
paidOutAtcompletedAt
toMethodpaymentMethod.typeSee Payment method values. paymentMethod is null until requested.
toAssetpaymentMethod.assetThe same CAIP-19 asset id. Present only when type is crypto.
—paymentMethod.currencyNew. The currency the money settles in. It can differ from currency.
senderInvoice, recipientInvoicesenderInvoiceUrl, recipientInvoiceUrl
recipientInvoiceSourcerecipientInvoiceSourceself-billing becomes self_billing. recipient-upload becomes recipient_upload.
createdAt, updatedAtcreatedAt, updatedAt
url—Mint one with POST /sessions.
action (create response)—A create is always a create. Status 201.
—failureCode, failureReason, approvedAt, claimLinkSentAt, expiresAtNew.

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 statusv2 status
createdcreated
approvedapproved
requestedrequested
completedcompleted, 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.
expiredexpired

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 toMethodv2 paymentMethod.type
bank-ibanbank_iban
bank-swiftbank_swift
bank-achbank_ach
bank-wirebank_wire
bank-ukbank_uk
paypalpaypal
venmovenmo
cryptocrypto, 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.

  • 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.
  • A payout with a future availableOn cannot be claimed or paid before that date. 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.
# 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:

  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, 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 for a full example.

  2. 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" }
      }'

    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.

  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

v1v2Notes
POST /session/payout with payoutIdPOST /sessions with type: "payout_claim", payoutIdpayoutId is payout_… or your customId.
POST /session/approval with payoutIdsPOST /sessions with type: "payout_approval", payoutIdsEach id is payout_… or your customId.
POST /session/kybPOST /sessions with type: "kyb"
POST /session/allowancePOST /sessions with type: "allowance"
redirectUrl, branding, loginHintredirectUrl, branding, loginHintloginHint 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 201Redirect the visitor to url. The URL does not expire.

Team

v1 GET /teamv2Notes
id, teamUuidGET /team: idteam_….
name, kybStatusGET /team: name, kybStatuskybStatus 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[].balanceGET /team/accounts: data[].currency, data[].balance.totalOne account per currency. Not paged.
balances[].openPayoutAmountdata[].balance.reserved
balances[].balanceAfterPendingPayoutsdata[].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 queryv2 query
offsetcursor: 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.

v1v2
INPUT_VALIDATION_FAILED, 422, data.fieldErrorsVALIDATION_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 JSONMALFORMED_REQUEST, 400
BAD_REQUEST, counterparty not foundCOUNTERPARTY_NOT_FOUND, 404
BAD_REQUEST, availableOn is not in the futureVALIDATION_FAILED, 400, path ["availableOn"]
BAD_REQUEST, payout already existsCUSTOM_ID_TAKEN, 409, details.customId, details.id
UNAUTHORIZED, 401UNAUTHORIZED, 401
FORBIDDEN, missing scope (also preApproved without payouts:approve)MISSING_SCOPE, 403
FORBIDDEN, team not verified, on preApprovedVERIFICATION_REQUIRED, 422, details.missing
FORBIDDEN, no allowance, on preApprovedALLOWANCE_REQUIRED, 422
NOT_FOUND, payoutPAYOUT_NOT_FOUND, 404
500, webhook not foundWEBHOOK_NOT_FOUND, 404
—NOT_FOUND, 404: no endpoint matches the method and path
CONFLICT, update of a manual-immutable payoutPAYOUT_LOCKED, 409, details.status
CONFLICT, deletePAYOUT_NOT_DELETABLE, 409, details.status
CONFLICT, KYB session after the application was submittedKYB_ALREADY_SUBMITTED, 409
FORBIDDEN, white-label branding not available on a sessionBRANDING_UNAVAILABLE, 403
BAD_REQUEST, approval session with a payout that is unknown or not pending approvalPAYOUT_NOT_FOUND, 404, or PAYOUT_NOT_APPROVABLE, 409, details.status; both with index
INSUFFICIENT_BALANCE, 422, data.walletBalance, data.totalOpenAmount (numbers), data.currencyINSUFFICIENT_BALANCE, 422, details.total, details.required (decimal strings), details.currency
INTERNAL_SERVER_ERROR, 500INTERNAL_ERROR, 500

New codes in v2:

CodeStatusdetails
PAYOUT_NOT_EDITABLE409status
PAYOUT_NOT_APPROVABLE409status
PAYOUT_NOT_PAYABLE409status
PAYOUT_NOT_CLAIMABLE409status
PAYOUT_NOT_YET_AVAILABLE409availableOn
RECIPIENT_EMAIL_MISSING422counterpartyId
RECIPIENT_NOT_COUNTERPARTY422
COUNTERPARTY_NOT_PAYABLE422counterpartyId, missing
COUNTERPARTY_MANAGED_BY_RECIPIENT409
COUNTERPARTY_TYPE_FIXED409
COUNTERPARTY_IN_USE409
IDEMPOTENCY_KEY_REUSED409
IDEMPOTENCY_KEY_IN_PROGRESS409

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.*"] }'
v1v2
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 bodyv2 type
createdpayout.created. A payout that goes back to created sends payout.updated.
approvedpayout.approved
requestedpayout.requested
completedpayout.completed
deletedpayout.deleted, with data { object, id, deleted: true }
expiredpayout.expired
—payout.updated, payout.failed, counterparty.created, counterparty.updated, counterparty.deleted, webhook.ping
  • payout.updated is sent when a payout field changes. 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, payout.approved, and payout.requested. data is the payout at the time of the event. For the current state, read GET /payouts/{id}; a deleted payout answers 404.
  • payout.failed is 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.

v1v2
HeadersX-Talentir-Signature, X-Talentir-Timestampwebhook-id, webhook-timestamp, webhook-signature
Signed content{timestamp}.{body}{webhook-id}.{webhook-timestamp}.{body}
HMAC-SHA256 keyThe full secret, whsec_ includedThe base64-decoded part of the secret after whsec_
SignatureLowercase hex, no prefixBase64 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 valid

Each 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

v1v2
payouts:read, payouts:write, payouts:approveunchanged
—counterparties:read, counterparties:write
team:read, webhooks:read, webhooks:write, sessions:writeunchanged
v2 endpointScope
GET /payouts, GET /payouts/{id}payouts:read
POST /payouts, POST /payouts/batch, PATCH /payouts/{id}, DELETE /payouts/{id}, send-claim-link, paypayouts:write
andThen on createalso payouts:approve
pay on a failed payoutalso payouts:approve
POST /payouts/{id}/approve, POST /payouts/approvepayouts:approve. With andThen: "pay", also payouts:write.
GET /counterparties, GET /counterparties/{id}counterparties:read
GET /countries, GET /payment-method-typescounterparties:read or counterparties:write
POST, PATCH, and DELETE on /counterpartiescounterparties:write
GET /webhooks, GET /webhooks/{id}, deliverieswebhooks:read
POST, PATCH, and DELETE on /webhooks, rotate-secret, testwebhooks:write
POST and PATCH on /webhooks with payout or counterparty eventsalso payouts:read or counterparties:read
POST /sessionssessions:write
GET /team, GET /team/members, GET /team/accountsteam: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/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.
  • A 4xx response is stored and replayed too, except MISSING_SCOPE and UNAUTHORIZED. 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 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 response fields are never omitted; they are null. Arrays are never null.
  • 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, currency is exactly USD, EUR, CHF, or GBP. See Changes without a new version for what can change.

Checklist

  1. Switch the base URL to /api/v2. Test in the sandbox first.
  2. Prefix stored ids: payout_, counterparty_, team_.
  3. Rename fields per the field map. Send amount, not payoutAmount. Send locked, not payoutType. Send andThen: "approve", not preApproved.
  4. Replace verificationMethod, email, phone, and counterpartyId with the recipient object. Stop creating handle and wallet payouts.
  5. Remove every v1-only field from request bodies. v2 rejects unknown fields.
  6. Replace update-by-POST with PATCH /payouts/{id}.
  7. Add the step after approval: send-claim-link, pay, or your own link from POST /sessions.
  8. Map status, paymentMethod.type, and kybStatus values. Handle the new status failed.
  9. Expect 201 on every create.
  10. Replace offset paging with cursor. Read data from the list envelope.
  11. Read error.code from the new error shape. Move validation handling from 422 to 400.
  12. Move sessions to POST /sessions with type. Send prefixed payout ids or your customIds.
  13. Read balances from GET /team/accounts and members from GET /team/members.
  14. 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.
  15. Add counterparties:* to your key. Ask Talentir for payouts:approve if you approve through the API.
  16. Send Idempotency-Key on every POST.
  17. Delete the v1 webhook with v1 DELETE /api/v1/webhook/{id} once the v2 webhook is verified.