Beta. This endpoint is not final. Field names and response shapes can change before general availability.
Why retries need a key
Network failures happen after the server has already done the work. Your client sends POST /v1/calls, the connection drops before the response returns, and you have no way to tell whether the call was placed. Retrying blindly places a second call to the same person.
Idempotency solves this. You attach a key to a write request. If Finn has already processed a request with that key, it returns the original response instead of doing the work again.
Sending a key
Set the Idempotency-Key header on any write request. Generate a fresh UUID per logical operation, not per HTTP attempt. All retries of the same operation reuse the same key.
curl -X POST https://api.hirefinn.ai/v1/calls \
-H "Authorization: Bearer $FINN_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8f1c1a6e-3b8d-4a2f-9f1e-2c7d5b0a4e11" \
-d '{
"deployment_id": "dep_9x2k4m",
"to_number": "+14155550142",
"variables": {
"first_name": "Dana",
"appointment_time": "2026-08-02T15:00:00Z"
}
}'
The replayed response carries a header marking it as a replay. The header name is not settled yet. Do not build logic that depends on it. Rely on the response body, which is byte-identical to the original.
Which methods honour it
| Method | Behaviour |
|---|---|
POST | Key honoured. Use it. |
PATCH | Key honoured. |
PUT | Key honoured, though replacing a resource is already repeatable. |
DELETE | Key accepted and ignored. Deleting twice has the same end state. |
GET | Key ignored. Reads change nothing. |
What happens when keys collide
| Situation | Result |
|---|---|
| Same key, same request body | Original response replayed, no new work. |
| Same key, different request body | 409 Conflict. Finn will not guess which one you meant. |
| Same key, original request still in flight | 409 Conflict. Wait and retry with the same key. |
| Key reused after the retention window | Treated as a new request. Work happens again. |
No key on a POST | No protection. A retry places a second call. |
The retention window for keys is not finalised. Assume it is short, on the order of a day rather than a month, and do not use idempotency keys as a long-term deduplication store for your own business logic. If you need to guarantee one call per contact per campaign, enforce that in your own database and see outbound campaigns for the audience-level controls.
Failures are recorded too
A 4xx response is a completed outcome. If you send a key with a malformed body and get 422, retrying with the same key replays that 422. Fix the body and use a new key.
5xx responses and connection failures are not recorded as outcomes. Retrying the same key after a 500 re-attempts the work. This is what you want: the write may never have happened.
A retry loop that behaves
import { randomUUID } from "node:crypto";
async function placeCall(body: Record<string, unknown>) {
const key = randomUUID();
for (let attempt = 0; attempt < 4; attempt++) {
const res = await fetch("https://api.hirefinn.ai/v1/calls", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FINN_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(body),
});
if (res.ok) return res.json();
// 409 means an identical request is still processing. Back off and reuse the key.
// 429 and 5xx are safe to retry with the same key.
if (res.status === 409 || res.status === 429 || res.status >= 500) {
await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
continue;
}
// Any other 4xx is your bug. Retrying replays the same failure.
throw new Error(`${res.status}: ${await res.text()}`);
}
throw new Error("exhausted retries");
}
placeCall({ deployment_id: "dep_9x2k4m", to_number: "+14155550142" });
import os, time, uuid, requests
def place_call(body):
key = str(uuid.uuid4())
for attempt in range(4):
res = requests.post(
"https://api.hirefinn.ai/v1/calls",
headers={
"Authorization": f"Bearer {os.environ['FINN_API_KEY']}",
"Idempotency-Key": key,
},
json=body,
timeout=30,
)
if res.ok:
return res.json()
if res.status_code in (409, 429) or res.status_code >= 500:
time.sleep(2 ** attempt * 0.5)
continue
res.raise_for_status()
raise RuntimeError("exhausted retries")
place_call({"deployment_id": "dep_9x2k4m", "to_number": "+14155550142"})
Common mistakes
Generating the key inside the retry loop defeats the whole mechanism. Every attempt looks like a new request and you place duplicate calls.
Hashing the request body into the key looks clever and breaks the conflict check. Two genuinely distinct operations with identical bodies, such as two calls to the same number an hour apart, collapse into one.
Reusing one key across an entire batch means only the first item in the batch is ever created.
Sharing keys across API keys or workspaces is not supported. Scope is per workspace, so a key used under one credential does not deduplicate a request sent under another.
Related
- API conventions for headers and request format
- Errors for the full status code list
- Rate limits for
429handling and backoff - Webhook retries for the same problem in the other direction