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 aurltoevents. The response carries the signingsecretonce.GET /webhooks,GET /webhooks/{id}— list and read your subscriptions (secretisnullhere).PATCH /webhooks/{id}— changeurl,events, orenabled.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 awebhook.pingevent, with the webhook asdata, and return its delivery.GET /webhooks/{id}/deliveries— recent delivery attempts, newest first, withstatus,responseStatus,attempt, andnextRetryAt.
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
| Type | When |
|---|---|
payout.created | POST /payouts, or a dashboard create |
payout.updated | PATCH /payouts/{id}, or a counterparty change moved the payout back to created |
payout.approved | Approval |
payout.requested | The recipient's claim, or POST /payouts/{id}/pay |
payout.completed | The transfer settled |
payout.failed | The provider rejected the transfer; data.failureCode and data.failureReason say why |
payout.deleted | Delete |
payout.expired | Expiry |
counterparty.created | POST /counterparties, or a recipient claimed and a linked counterparty appeared |
counterparty.updated | PATCH /counterparties/{id}, or the recipient changed their details |
counterparty.deleted | Delete |
webhook.ping | POST /webhooks/{id}/test. The webhook is data. |
The list is open: new types appear without notice. Subscribe with wildcards and ignore types you do not handle.
Event payload
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 eventid. 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, eachv1,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
urlinstead. - 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 standardwebhooksimport { 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
- Read
webhook-id,webhook-timestamp, andwebhook-signaturefrom the request headers. - Reject the request when
webhook-timestampis more than 5 minutes away from now. This prevents replay attacks. - Decode the part of the secret after
whsec_from base64. This is the key. - Compute the HMAC-SHA256 of
{webhook-id}.{webhook-timestamp}.{raw body}with the key. Encode it as base64 and putv1,in front. - 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.