API reference · version 2026-09-26

    CallFlow 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

    1. Get a receiving URL from your tool (Zapier: Webhooks by Zapier → Catch Hook; GoHighLevel: Workflow → Inbound Webhook; or your own HTTPS endpoint).
    2. In Integrations, click Add endpoint, paste the URL and tick call.completed (add call.started if you want screen-pops).
    3. Click Send test event. A sample call.completed with "test": true arrives within a couple of seconds — use it to map fields in your tool.
    4. 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-event
    string
    The event type, e.g. call.completed.
    x-callflow-event-id
    string
    Unique per event. Retries reuse it — deduplicate on this.
    x-callflow-delivery-id
    string
    Unique per delivery attempt row (differs per endpoint).
    x-callflow-attempt
    integer
    1 for the first try, then 2, 3… on retries.
    x-callflow-signature
    string
    t=<unix seconds>,v1=<hex HMAC-SHA256> — see Verifying signatures.
    user-agent
    string
    CallFlow-Webhooks/1.0

    Respond 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.started
    event
    A call reached your account (ringing). Use it for screen-pops.
    call.completed
    event
    The call ended. Includes duration, cost and the recording link when available.
    call.recording_ready
    event
    A recording was attached to a call (may arrive after call.completed).
    call.disposition_updated
    event
    Someone tagged the call (Sale, Callback, …) in the dashboard or via the API.

    call.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
        }
      }
    }
    id
    integer
    Stable numeric call id. Use it for GET /calls/:id and dispositions.
    direction
    inbound | outbound
    Inbound = a caller dialled one of your numbers. Outbound = an agent dialled out.
    status
    string
    One of ringing, queued, connected, on-hold, completed, missed, no-answer, busy, failed, rejected, canceled, transferred.
    answered
    boolean
    Whether a human (or the destination) picked up.
    caller_number / did_number / destination_number
    E.164 string | null
    Who called, which of your numbers they dialled, and where the call was routed.
    campaign / target / agent
    object | null
    Routing context. agent is set when an extension (softphone) agent answered.
    duration_seconds
    integer
    Billable talk time. hold_seconds is queue wait, when the queue was used.
    cost
    decimal string
    What the call cost your account in USD. Never rounded.
    recording_url
    string | null
    Direct, signed link valid for 30 days (recordings themselves are retained for 10 days). Download it into your CRM if you need it longer.
    disposition / disposition_note
    string | null
    Your tag for the call. See Dispositions.

    Fields 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.

    GET/me
    Returns the account and key the token belongs to. Handy as a connection test.
    GET/calls

    Newest first. Returns { data: Call[], has_more, next_cursor }. Page with starting_after=<next_cursor>.

    limit
    1–100, default 25
    Page size.
    starting_after
    call id
    Return calls older than this id (cursor from the previous page).
    started_after / started_before
    ISO timestamp
    Filter on started_at.
    ended_after
    ISO timestamp
    Only calls that ended after this time — the natural filter for polling completed calls.
    status / direction / campaign_id
    string / string / integer
    Exact-match filters.
    caller_number
    digits
    Substring match on the caller (min 4 digits).
    answered
    true | false
    Only answered / only unanswered calls.
    has_recording
    true
    Only calls with a recording.
    disposition
    label | any | none
    Calls with this label (case-insensitive), any label, or none.
    sort
    started_at | disposition_updated_at
    Use disposition_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"
    GET/calls/:id
    One call.
    PATCH/calls/:id

    Set 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.

    GET/campaigns
    Your campaigns (id, name, status, created_at) — useful for filter dropdowns.
    GET/webhooks
    List webhook endpoints (secrets are not included).
    POST/webhooks

    Create 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.

    GET/webhooks/:id
    One endpoint with its health counters.
    DELETE/webhooks/:id
    Remove an endpoint. Pending deliveries are discarded. Returns 204.
    GET/events/sample?type=call.completed
    Up to 10 recent events for that type built from your real calls (or a fixture if you have none yet), in the exact webhook shape. Use it to test receivers and map fields.

    Dispositions

    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

    1. In GHL: Automation → Workflows → Create workflow → Inbound Webhook trigger. Copy the webhook URL.
    2. In CallFlow Integrations: Add endpoint, paste it, select call.completed (and call.disposition_updated if you tag calls in CallFlow). Click Send test event.
    3. 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.
    4. Add Create/Update Contact, then an If/Else on data.call.answered or duration_seconds to route long calls to a pipeline stage and missed calls to a call-back task or SMS.
    5. Optional write-back: when the opportunity moves to Won, add a Webhook action: PATCH https://callflow.solutions/api/v1/calls/{{call id}}, header Authorization: Bearer cf_live_…, body {"disposition":"Sale"}. Store data.call.id on 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-Remaining and X-RateLimit-Reset; a 429 includes Retry-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.