Idempotency keys
A request can fail after Finn has already done the work. If you send POST /calls and the connection drops before the response arrives, you can't tell whether the call was placed. If you retry without protection, you may call the same person twice.
The writes that spend money accept an Idempotency-Key header:
POST /callsPOST /deploymentsPOST /deployments/inboundPOST /phone_numbersPOST /agents/{id}/cases(start a case) also takes one, with its own rules: see api-cases.
Every other endpoint ignores the header. This page explains how the key works and how to retry the other writes safely.
How the key works
Make up a unique key for each piece of work, such as a UUID, or an ID from your own database like call-<contact>-<campaign>. Send it on the first request, and send the same key with the same body on every retry of it.
curl -X POST https://api.hirefinn.ai/api/v1/calls \
-H "Authorization: Bearer $FINN_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 3f6c1d2e-8a4b-4c7e-9f10-5b2a7d9e0c41" \
-d '{"finn_id": "8f14e45f-ceea-467a-9f6a-1c0e5b2a77d1", "to_number": "4155550142", "country_code": "1"}'
| What happens | What you get |
|---|---|
| First request with the key | Finn does the work and saves the response for 24 hours. |
| Retry with the same key and the same body | The saved response, with the same status and body, plus the header Idempotent-Replayed: true. Nothing is done again. |
| Retry while the first request is still running | 409 idempotency_in_progress with Retry-After. Wait and retry with the same key to get the result. |
| Same key with a different body or endpoint | 422 idempotency_key_reused. Nothing is done. Use a new key for new work. |
| Key that isn't 1 to 255 visible ASCII characters | 400 invalid_request. |
The rules behind this:
- Keys belong to your organization. Two API keys in the same organization share them. Another organization can use the same key without conflict.
- "Same body" means the same JSON values. The order of fields doesn't matter.
2xxand5xxresponses are saved. A5xxcan come after real work, for example the carrier dialed and then a downstream service timed out, so the safe answer to a retry is the same5xx. If you know nothing happened and want to try again, use a new key.- A
4xxfrees the key. It means Finn refused the request before doing anything, so you can fix the problem, for example top up your wallet after a402, and send the request again with the same key. - Keys expire after 24 hours. After that the same key starts new work.
- A replay still counts against your rate limit.
Which failures are uncertain
| What you got back | Did the write happen? |
|---|---|
2xx | Yes. |
429 | No. Finn rejected the request before doing anything. Retrying is safe. |
400, 401, 402, 403, 404, 409, 422 | No. Finn rejected the request. Don't retry until you've fixed the cause. |
500, 502, a timeout or a dropped connection | Unknown. Retry with the same Idempotency-Key where the endpoint takes one; otherwise check before you retry. |
Write endpoints
| Endpoint | Retrying blindly | Before you retry |
|---|---|---|
POST /calls | Safe with an Idempotency-Key. Unsafe without it: it can place a second call. | Send Idempotency-Key. See Placing calls. |
POST /deployments | Safe with an Idempotency-Key. Without it, mostly safe. A Finn can have only one live deployment, so a retry gets 409 deployment_already_active with the existing deployment_id. | If the first deployment has already finished, a retry starts a new one. Check GET /deployments?status=... first if that would be a problem. |
POST /deployments/inbound | Safe with an Idempotency-Key. Without it, mostly safe. A retry for the same Finn and number usually gets a 409, either deployment_already_active or phone_number_in_use. | Check GET /deployments?call_type=inbound. |
POST /deployments/{id}/stop | Safe. A retry gets 409 deployment_not_active. Treat that as success. | |
POST /deployments/inbound/{finn_id}/stop | Safe. A retry gets 404 deployment_not_found. Treat that as success. | |
POST /finns | Unsafe. It creates a second draft Finn. | Look for the name in GET /finns. If a duplicate draft was created, remove it with DELETE /finns/{finn_id}. |
PATCH /finns/{finn_id} | Safe. Sending the same changes again gives the same result. | |
DELETE /finns/{finn_id} | Safe. A retry gets 404 finn_not_found. Treat that as success. | |
POST /phone_numbers | Safe with an Idempotency-Key. Without it, unsafe, though it doesn't charge twice. The wallet is debited only once per number for your organization, but a retry records another purchase and starts the number's setup again. | Look for the number in GET /phone_numbers. Setup finishes after the 202, so the number can take a moment to appear. |
DELETE /phone_numbers/{id} | Safe. A retry gets 404 phone_number_not_found. Treat that as success. | |
POST /audiences | Unsafe. It creates a second audience. | Look for the name in GET /audiences. Archive a duplicate with DELETE /audiences/{audience_id}. |
POST /audiences/{audience_id}/contacts | Safe. A contact whose country code and phone number are already in the audience is skipped and counted in duplicates_skipped. | |
DELETE /audiences/{audience_id}/contacts/{phone} | Safe. A retry gets 404 contact_not_found. Treat that as success. | |
DELETE /audiences/{audience_id} | Safe. A retry gets 404 audience_not_found. Treat that as success. | |
POST /contacts | Safe. A retry gets 409 contact_exists with the contact_id. Treat that as success. | |
PATCH /contacts/{id} | Safe. Sending the same changes again gives the same result. | |
POST /contacts/{id}/notes | Safe with client_id: the same client_id returns the first note. Unsafe without it. | Send a client_id. |
POST /agents/{id}/cases | Safe with an Idempotency-Key header: a repeat returns the first answer with replayed: true. Without it, subject still limits it to one open case per subject. | Send Idempotency-Key. |
POST /approvals/{id}/decide, /approve, /reject | Safe. A retry after the decision gets 409 conflict. | GET /approvals/{id} and check status, answer and decided_by. |
Placing calls
POST /calls is the write where an accidental duplicate costs the most: two calls to one person. Use the Idempotency-Key header and keep your own record of each call:
- Save your intent before you send. Write a row in your own database for the call you're about to place, keyed on whatever makes it unique for you, such as contact plus campaign. Mark the row pending. Use the row's ID as the
Idempotency-Key. - Save the
call_uuidwhen you get a202. Mark the row placed. - When the result is uncertain, retry with the same key. After a timeout, a dropped connection or a
409 idempotency_in_progress, send the same request with the same key. If the first request placed the call, you get its202andcall_uuidback and no second call is made. A5xxis replayed as the same5xx. - After a replayed
5xx, reconcile before you use a new key. A502 call_failedusually means no call was placed, but Finn can't always be sure. If a call was placed, a call.completed event arrives whosedata.finn_idanddata.to_numbermatch your row.data.to_numberis in E.164 format, so compare it with the full international number, not the digits you sent. You can also check Call History in the dashboard. Retry with a new key only when that wait passes with no event.
Keys last 24 hours, so finish retrying a call within a day.
type Pending = { id: string; finnId: string; toNumber: string; countryCode: string };
async function placeCall(p: Pending): Promise<string | null> {
await db.markPending(p.id);
for (let attempt = 0; attempt < 5; attempt++) {
let res: Response;
try {
res = await fetch("https://api.hirefinn.ai/api/v1/calls", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.FINN_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `call-${p.id}`,
},
body: JSON.stringify({
finn_id: p.finnId,
to_number: p.toNumber,
country_code: p.countryCode,
}),
signal: AbortSignal.timeout(110_000),
});
} catch {
await sleep(2 ** attempt * 1000); // Uncertain. The same key makes the retry safe.
continue;
}
if (res.status === 202) {
const { data } = await res.json();
await db.markPlaced(p.id, data.call_uuid);
return data.call_uuid;
}
if (res.status === 409 || res.status === 429) {
// Still running, or rate limited: nothing new happened. Wait and send the same request again.
await sleep(Number(res.headers.get("Retry-After") ?? 2) * 1000);
continue;
}
if (res.status >= 500) return null; // Replayed on every retry. Leave pending and reconcile from webhooks.
const body = await res.json();
await db.markFailed(p.id, body.error); // 4xx: definitely not placed.
return null;
}
return null; // Leave pending.
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
import os, time, requests
def place_call(p):
db.mark_pending(p["id"])
for attempt in range(5):
try:
res = requests.post(
"https://api.hirefinn.ai/api/v1/calls",
headers={
"Authorization": f"Bearer {os.environ['FINN_API_KEY']}",
"Idempotency-Key": f"call-{p['id']}",
},
json={
"finn_id": p["finn_id"],
"to_number": p["to_number"],
"country_code": p["country_code"],
},
timeout=110,
)
except requests.RequestException:
time.sleep(2 ** attempt) # Uncertain. The same key makes the retry safe.
continue
if res.status_code == 202:
call_uuid = res.json()["data"]["call_uuid"]
db.mark_placed(p["id"], call_uuid)
return call_uuid
if res.status_code in (409, 429):
time.sleep(float(res.headers.get("Retry-After", 2)))
continue
if res.status_code >= 500:
return None # Replayed on every retry. Leave pending.
db.mark_failed(p["id"], res.json()["error"])
return None
return None
Allow at least 110 seconds for the client timeout. Finn can take up to 100 seconds to respond to POST /calls. If your timeout is shorter, more of your requests end up uncertain.
Webhooks
Webhook deliveries don't have an event ID. Finn sends at most one call.completed per endpoint for each call, but your own systems can still process a delivery twice, for example after a crash between receiving and saving. Use event plus data.call_uuid as your dedupe key. See webhook-retries.
Common mistakes
Retrying POST /calls without a key. A general retry helper that resends POSTs after a 502 or a timeout will sometimes call the same person twice. Send an Idempotency-Key, or check first.
Treating a 404 on a retried delete as a failure. It means the first attempt worked.
Expecting the header to work everywhere. Only the endpoints listed above honour Idempotency-Key. On the others, for example POST /finns or POST /audiences, the header is ignored and a retry does the work again.
A new key on every retry. Generating the key inside your retry loop makes every attempt new work. Create it once, with the record of what you're doing, and reuse it.
Reusing a key for new work. A key that already placed a call returns that call for 24 hours. Use one key per call, deployment or rental.
Related
- API conventions: request format and IDs
- Errors: every status and error code
- Rate limits: handling
429 - Webhook retries: duplicate handling for webhooks