There is no idempotency key
The Finn API doesn't support an Idempotency-Key header. If you send one, Finn ignores it. Finn doesn't store the response to a write, so it can't give you the original response when you retry.
This matters because 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 checking, you may call the same person twice.
This page explains how to retry each write safely.
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. Check before you retry. |
Write endpoints
| Endpoint | Retrying blindly | Before you retry |
|---|---|---|
POST /calls | Unsafe. It can place a second call. | See Placing calls. |
POST /deployments | 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 | 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 | 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. |
Placing calls
POST /calls is the write where an accidental duplicate costs the most: two calls to one person. There's also no endpoint that lists calls, so when you didn't get a response, you have no call_uuid to look up.
The safest approach:
- 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.
- Save the
call_uuidwhen you get a202. Mark the row placed. - When the result is uncertain, don't retry yet. Leave the row pending. If the call was placed, a call.completed event will arrive whose
data.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 only after the waiting period passes with no event. Choose a period longer than your longest expected call plus analysis time. Retry with care: a call that was placed but never analyzed may not produce an event.
A 502 call_failed usually means no call was placed, but Finn can't always be sure, so treat it as uncertain whenever a duplicate call would be a problem.
type Pending = { id: string; finnId: string; toNumber: string; countryCode: string };
async function placeCall(p: Pending): Promise<string | null> {
await db.markPending(p.id);
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",
},
body: JSON.stringify({
finn_id: p.finnId,
to_number: p.toNumber,
country_code: p.countryCode,
}),
});
} catch {
return null; // Uncertain. Leave pending and reconcile from webhooks.
}
if (res.status === 202) {
const { data } = await res.json();
await db.markPlaced(p.id, data.call_uuid);
return data.call_uuid;
}
if (res.status === 429) {
await db.markRetryable(p.id); // Nothing happened. Safe to send again later.
return null;
}
if (res.status >= 500) return null; // Uncertain. Leave pending.
const body = await res.json();
await db.markFailed(p.id, body.error); // 4xx: definitely not placed.
return null;
}
import os, requests
def place_call(p):
db.mark_pending(p["id"])
try:
res = requests.post(
"https://api.hirefinn.ai/api/v1/calls",
headers={"Authorization": f"Bearer {os.environ['FINN_API_KEY']}"},
json={
"finn_id": p["finn_id"],
"to_number": p["to_number"],
"country_code": p["country_code"],
},
timeout=40,
)
except requests.RequestException:
return None # Uncertain. Leave pending and reconcile from webhooks.
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 == 429:
db.mark_retryable(p["id"])
return None
if res.status_code >= 500:
return None # Uncertain. Leave pending.
db.mark_failed(p["id"], res.json()["error"])
return None
Allow at least 35 seconds for the client timeout. Finn can take up to 30 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 any 5xx on POST /calls. A general retry helper that resends POSTs after a 502 or a timeout will sometimes call the same person twice. Retry reads automatically. For writes, check first.
Treating a 404 on a retried delete as a failure. It means the first attempt worked.
Relying on the header. Finn ignores Idempotency-Key. It doesn't stop duplicates.
Related
- API conventions: request format and IDs
- Errors: every status and error code
- Rate limits: handling
429 - Webhook retries: duplicate handling for webhooks