API Docs
Guides

Set up webhooks

Receive real-time notifications for payout and counterparty events.

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

v2 webhooks follow Standard Webhooks. An official Standard Webhooks library verifies them for you.

Manage subscriptions

  • POST /webhooks — subscribe a url to events. The response carries the signing secret once.
  • GET /webhooks, GET /webhooks/{id} — list and read your subscriptions (secret is null here).
  • PATCH /webhooks/{id} — change url, events, or enabled.
  • DELETE /webhooks/{id} — remove one.
  • POST /webhooks/{id}/rotate-secret — replace the secret; the response carries the new one once. See Rotate the secret.
  • POST /webhooks/{id}/test — send a webhook.ping event, with the webhook as data, and return its delivery.
  • GET /webhooks/{id}/deliveries — recent delivery attempts, newest first, with status, responseStatus, attempt, and nextRetryAt.
curl -X POST "$BASE_URL/webhooks" \
  -H "Authorization: Bearer $TALENTIR_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/talentir", "events": ["payout.*", "counterparty.*"] }'

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

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

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

Event types

TypeWhen
payout.createdPOST /payouts, or a dashboard create
payout.updatedPATCH /payouts/{id}, or a counterparty change moved the payout back to created
payout.approvedApproval
payout.requestedThe recipient's claim, or POST /payouts/{id}/pay
payout.completedThe transfer settled
payout.failedThe provider rejected the transfer; data.failureCode and data.failureReason say why
payout.deletedDelete
payout.expiredExpiry
counterparty.createdPOST /counterparties, or a recipient claimed and a linked counterparty appeared
counterparty.updatedPATCH /counterparties/{id}, or the recipient changed their details
counterparty.deletedDelete
webhook.pingPOST /webhooks/{id}/test. The webhook is data.

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

Event payload

Each delivery is an HTTP POST with an event envelope. data is the resource as GET returns it at event time, with every field present and null where empty. A *.deleted event carries what DELETE returns, for example { "object": "payout", "id": "payout_…", "deleted": true }: the resource is gone from the API. timestamp is when the transition happened.

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

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

Delivery behavior

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

Request headers:

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

v2 webhooks do not send the X-Talentir-* headers of v1. A v1 webhook keeps its X-Talentir-Signature scheme; see Migrate from v1 to v2.

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

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

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

Disabling: when 25 events in a row fail on every attempt, the subscription is disabled (enabled: false). Fix the endpoint, check GET /webhooks/{id}/deliveries, then send PATCH /webhooks/{id} with { "enabled": true }. This resets the count.

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

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

Verify signatures

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

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

With a library

Use an official Standard Webhooks library. Libraries exist for many languages. For Node:

npm install standardwebhooks
import { Webhook } from "standardwebhooks";

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

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

Without a library

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

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

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

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

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

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

Rotate the secret

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