2026-09-26CallFlow API & Webhooks
Get every call your numbers receive delivered to your CRM the moment it happens, and read or tag calls from your own tools. No SDK required — HTTPS, JSON and an API key.
Overview
Two building blocks:
- Webhooks — we POST a signed JSON event to your URL when a call starts, ends, gets a recording, or is tagged. Point it at Zapier, GoHighLevel, Make, n8n, or your own server.
- REST API — pull calls, campaigns and recordings, and write dispositions (Sale, Callback…) back into CallFlow. Base URL
https://callflow.solutions/api/v1.
Both are managed from Dashboard → Integrations by the account owner. Sub-users, buyers and agents cannot create keys or endpoints. Integrations are switched on per account — if you do not see the page, ask us to enable it.
Authentication
Create an API key in Integrations. Keys look like cf_live_…, are shown once, and carry full access to your account. Send it as a Bearer token:
curl https://callflow.solutions/api/v1/me \
-H "Authorization: Bearer cf_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"Revoke a key at any time; anything using it fails with 401 immediately.
Quickstart — calls into your CRM in five minutes
- Get a receiving URL from your tool (Zapier: Webhooks by Zapier → Catch Hook; GoHighLevel: Workflow → Inbound Webhook; or your own HTTPS endpoint).
- In Integrations, click Add endpoint, paste the URL and tick
call.completed(addcall.startedif you want screen-pops). - Click Send test event. A sample
call.completedwith"test": truearrives within a couple of seconds — use it to map fields in your tool. - Take a real call. Every call on your account now arrives as it ends, with duration, caller, campaign, cost and a recording link.
Webhooks
Each event is delivered as an HTTP POST with Content-Type: application/json and these headers:
x-callflow-eventcall.completed.x-callflow-event-idx-callflow-delivery-idx-callflow-attemptx-callflow-signaturet=<unix seconds>,v1=<hex HMAC-SHA256> — see Verifying signatures.user-agentCallFlow-Webhooks/1.0Respond with any 2xx within 10 seconds. Anything else — including timeouts — is retried. Endpoints must be https:// and publicly reachable (no private IPs or localhost).
Event types
call.startedcall.completedcall.recording_readycall.disposition_updatedcall.completed waits up to ~10 seconds after hang-up so the recording link can be included when one exists. If the recording lands later, call.recording_ready follows with the same call object and a populated recording_url. Events for one call are emitted in order, but delivery order across retries is not guaranteed — use data.call.id as your key and treat each event as the latest snapshot of that call.
The call object
Every event and every REST response uses the same shape:
{
"id": "evt_9f2c3a5d1b7e4c8fa0d1e2f3a4b5c6d7",
"type": "call.completed",
"created_at": "2026-09-26T14:03:12.410Z",
"api_version": "2026-09-26",
"data": {
"call": {
"id": 184203,
"call_id": "fs_4c1d8e2a-…",
"direction": "inbound",
"status": "completed",
"answered": true,
"caller_number": "+14155550123",
"did_number": "+18885550199",
"destination_number": "+13105550144",
"campaign": { "id": 41, "name": "Auto Warranty – TV" },
"target": { "id": 87, "name": "Closer desk 1", "type": "phone", "destination": "+13105550144" },
"agent": null,
"started_at": "2026-09-26T14:00:41.000Z",
"answered_at": "2026-09-26T14:00:49.000Z",
"ended_at": "2026-09-26T14:03:02.000Z",
"duration_seconds": 133,
"hold_seconds": null,
"queue_outcome": null,
"cost": "0.0670",
"currency": "USD",
"recording_url": "https://callflow.solutions/api/recordings/…/play?exp=…&sig=…",
"recording_duration_seconds": 131,
"disposition": null,
"disposition_note": null,
"disposition_updated_at": null
}
}
}idGET /calls/:id and dispositions.directionstatusringing, queued, connected, on-hold, completed, missed, no-answer, busy, failed, rejected, canceled, transferred.answeredcaller_number / did_number / destination_numbercampaign / target / agentagent is set when an extension (softphone) agent answered.duration_secondshold_seconds is queue wait, when the queue was used.costrecording_urldisposition / disposition_noteFields are only ever added, never renamed or removed, within an API version. Carrier and routing internals are intentionally not exposed.
Verifying signatures
Each endpoint has its own signing secret (whsec_…, shown when you create it and available under Deliveries → Signing secret). The signature is HMAC-SHA256 over `${t}.${raw_body}`. Verify it before trusting a payload, and reject timestamps older than a few minutes to block replays.
Node.js (Express)
import { createHmac, timingSafeEqual } from "crypto";
// Express: keep the RAW body — signatures are computed over the exact bytes.
app.post("/callflow", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("x-callflow-signature") || ""; // "t=1727359392,v1=abc…"
const t = /t=(\d+)/.exec(header)?.[1];
const v1 = /v1=([a-f0-9]+)/.exec(header)?.[1];
if (!t || !v1) return res.status(400).end();
const expected = createHmac("sha256", process.env.CALLFLOW_WEBHOOK_SECRET)
.update(`${t}.${req.body.toString("utf8")}`)
.digest("hex");
const ok = expected.length === v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
if (!ok || Math.abs(Date.now() / 1000 - Number(t)) > 300) return res.status(401).end();
const event = JSON.parse(req.body);
// Dedupe on event.id — retries re-send the same id.
res.status(200).end(); // respond fast; do the work async
});Python
import hmac, hashlib, time, json
def verify(secret: str, header: str, raw_body: bytes) -> dict | None:
parts = dict(p.split("=", 1) for p in header.split(","))
t, v1 = parts.get("t"), parts.get("v1")
if not t or not v1:
return None
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, v1) or abs(time.time() - int(t)) > 300:
return None
return json.loads(raw_body)Zapier and GoHighLevel receive webhooks without verification; since those URLs are unguessable that is normal practice — just keep the URL private.
Retries & delivery log
Failed deliveries retry after 30 s, 2 min, 10 min, 30 min, 2 h, 6 h and 24 h (8 attempts in total, about 1.5 days). Every attempt — status code, response body excerpt, error — is visible under Deliveries in the dashboard, where you can also replay any event by hand. An endpoint that fails 50 times in a row is switched off automatically; fix the receiver and toggle it back on.
REST API
Base URL https://callflow.solutions/api/v1. All responses are JSON. Timestamps are ISO-8601 UTC.
/me/callsNewest first. Returns { data: Call[], has_more, next_cursor }. Page with starting_after=<next_cursor>.
limitstarting_afterstarted_after / started_beforestarted_at.ended_afterstatus / direction / campaign_idcaller_numberansweredhas_recordingdispositionsortdisposition_updated_at with disposition_updated_after to poll for newly tagged calls.curl "https://callflow.solutions/api/v1/calls?ended_after=2026-09-26T00:00:00Z&answered=true&limit=50" \
-H "Authorization: Bearer $CALLFLOW_API_KEY"/calls/:id/calls/:idSet or clear the disposition. Body: { "disposition": "Sale", "disposition_note": "Closed on the first call" }. Send null to clear. Returns the updated call and emits call.disposition_updated.
/campaignsid, name, status, created_at) — useful for filter dropdowns./webhooks/webhooksCreate an endpoint. Body: { "url": "https://…", "events": ["call.completed"], "description": "optional" }. The response includes the secret — this is the only time it is returned over the API.
/webhooks/:id/webhooks/:id204./events/sample?type=call.completedDispositions
A disposition is a short label your team or your CRM attaches to a call. Presets in the dashboard: Sale, No Sale, Callback, Not Qualified, Voicemail, Spam, Wrong Number. The API accepts any single-line label up to 40 characters, so you can mirror your CRM's own stage names. Notes are up to 1,000 characters. Changing a disposition — in the dashboard or via the API — emits call.disposition_updated, which is how a "Sale" tagged in CallFlow can move a contact in your CRM, or a stage change in your CRM can be reflected back on the call.
Zapier
Today: use Webhooks by Zapier. Create a Zap with the trigger Catch Hook, copy the URL into a CallFlow endpoint, click Send test event, then Test trigger in Zapier — the sample call appears with every field ready to map into Google Sheets, HubSpot, Pipedrive, Slack, or 7,000 other apps. To write dispositions back, add a Webhooks by Zapier → Custom Request step: PATCH https://callflow.solutions/api/v1/calls/{{id}} with the Bearer header and a JSON body.
A native CallFlow app in the Zapier directory (triggers New Call, Call Completed, Disposition Updated; actions Update Disposition, Find Call) is in preparation and uses this same API.
GoHighLevel recipe
- In GHL: Automation → Workflows → Create workflow → Inbound Webhook trigger. Copy the webhook URL.
- In CallFlow Integrations: Add endpoint, paste it, select
call.completed(andcall.disposition_updatedif you tag calls in CallFlow). Click Send test event. - Back in GHL, the sample appears under the trigger. Map
data.call.caller_number→ contact phone,data.call.campaign.name→ source/tag,data.call.recording_url→ a custom field or note,data.call.duration_seconds→ a custom field. - Add Create/Update Contact, then an If/Else on
data.call.answeredorduration_secondsto route long calls to a pipeline stage and missed calls to a call-back task or SMS. - Optional write-back: when the opportunity moves to Won, add a Webhook action:
PATCH https://callflow.solutions/api/v1/calls/{{call id}}, headerAuthorization: Bearer cf_live_…, body{"disposition":"Sale"}. Storedata.call.idon the contact in step 3 so it is available here.
Limits & errors
- 120 requests per minute per API key. Every response carries
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset; a429includesRetry-After. - Up to 10 active API keys and 10 webhook endpoints per account.
- Errors are JSON:
{ "error": { "type": "invalid_request_error", "message": "…", "param": "limit" } }. Types:authentication_error(401),account_error(403),not_found(404),invalid_request_error(422),rate_limit_error(429). - Delivery history is kept for 30 days.
Versioning & support
The current version is 2026-09-26 and is included in every event as api_version. Within a version we only add fields. Breaking changes ship as a new dated version with advance notice by email. Questions or a CRM you would like supported natively: contact us.