Push leads in. Get intents out.
REST over HTTPS, a typed Node package, and signed webhooks. The agent makes the call; your system finds out what happened on it — who said yes, to what, and in which words.
Sixty seconds
npm install @callex/node
import { Callex } from "@callex/node"; // requireMode: "test" is a guard, not a setting — it throws if the key // you handed it is a live one, so a staging deploy cannot dial a customer. const callex = new Callex({ requireMode: "test" }); await callex.leads.create({ phone: "+15555550142", external_ref: "00Q5g00000XyZab", // your primary key, echoed back on everything consent: { source: "web_form", captured_at: "2026-08-20T14:03:00Z" }, });
Zero dependencies, ESM and CJS. It wraps the same HTTP API below, doing the parts that are easy
to get wrong by hand: an Idempotency-Key on every write, retries that reuse it,
webhook signature verification, and cursor pagination that does not restart from the beginning
when a page comes back empty.
Or just curl it
curl -X POST https://callexapp.com/v1/leads \ -H "Authorization: Bearer ck_live_..." \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ -d '{ "phone": "+1 555 555 0142", "name": "Dana Whitfield", "external_ref": "00Q5g00000XyZab", "consent": { "source": "web_form", "captured_at": "2026-08-20T14:03:00Z", "text": "Yes, you may call me about my quote." }, "call_now": true, "goal": "Follow up on the quote sent Monday" }'
Consent is required, not advisory. Dialing a CRM row is TCPA territory in the
US and the penalties are statutory and per call. We record what you assert, timestamped, and
hand it back on every intent — so the audit trail exists and points at whoever created it. A
contact on the do-not-call list is refused with a 409 and stays refused; re-pushing
the row cannot un-suppress them.
Send Idempotency-Key on every POST. One day your HTTP client will
time out at ten seconds while we are still dialing, and retry. The key is what makes that retry
free: we recognise it, return the original result, and the prospect's phone rings once. Without
it, it rings twice — and they are the one who notices.
What comes back
Create a webhook destination and every intent the agent captures is posted to you, with the sentence that caused it and a link to the moment in the transcript.
{
"id": "int_...",
"type": "intent.captured",
"mode": "live",
"data": {
"intent": {
"kind": "quote_accepted",
"confidence": 0.94,
"confirmed": true,
"details": { "requested_time": "2026-09-02T15:00:00Z" },
"external_ref": "00Q5g00000XyZab"
},
// The words themselves, so a human can check the machine's reading.
"evidence": {
"quote": "yeah, the $2,400 is fine, let's book it",
"offset_ms": 128400,
"transcript_url": "https://callexapp.com/app/calls/call_..."
},
"contact": { "name": "Dana Whitfield", "external_ref": "00Q5g00000XyZab" },
"call": { "duration_sec": 164, "disposition": "resolved" }
}
}
Verify the signature
The header is callex-signature: t=<unix>,v1=<hex>, and the signed material
is "<t>.<raw body>".
import { createHmac, timingSafeEqual } from "node:crypto"; function verify(rawBody, header, secret) { const t = header.match(/t=(\d+)/)?.[1]; const v1 = header.match(/v1=([a-f0-9]+)/)?.[1]; if (!t || !v1) return false; // Reject anything older than five minutes, or the timestamp buys you nothing. if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); return timingSafeEqual(Buffer.from(v1), Buffer.from(expected)); }
Verify against the raw body, before any JSON parsing. Re-serialising changes the bytes and the signature will not match — which is the single most common reason a first integration fails, and it fails silently on the happy path.
Return any 2xx. Anything else is retried on a schedule of roughly 10s, 30s, 2m, 10m, 45m, 3h, 12h, then declared dead and left for a human to replay from the dashboard. An endpoint that has been gone all day is not coming back this hour, so a destination that racks up consecutive dead deliveries is paused rather than hammered.
Test mode
Keys are ck_test_ or ck_live_ and the mode is carried by the key, not by
a flag you can forget. A test key never dials a real telephone — it produces the
same objects, the same webhooks and the same signatures against a simulated call, so an
integration can be built end to end before anyone's phone rings.
Test-mode traffic is also never reported to billing. That is a promise the billing suite asserts on rather than one this page makes.
Endpoints
| Method & path | What it does |
|---|---|
GET /v1/ping | Check a key and see which mode it is in |
POST /v1/leads | Create a contact, optionally dial it now |
GET /v1/leads/{id} | One contact |
POST /v1/calls | Place a call |
GET /v1/calls | List calls, cursor paginated |
GET /v1/calls/{id} | One call, with its transcript |
GET /v1/intents | What the agent understood, across calls |
POST /v1/intents/{id}/outcome | Write back what actually happened |
POST /v1/intents/{id}/dispute | Tell us the agent read it wrong |
POST /v1/destinations | Create a webhook or REST destination |
POST /v1/destinations/{id}/test | Send a specimen event to it |
GET /v1/deliveries | Every attempt, with request and response |
POST /v1/deliveries/{id}/replay | Send a dead delivery again |
The full machine-readable contract is at
/v1/openapi.json — point your generator at it rather
than typing these by hand.
Writing back is the point
POST /v1/intents/{id}/outcome is how you tell us the booking held, the quote was
signed, or the lead went nowhere. It is not bookkeeping: it is the loop that lets us bill on
outcomes rather than minutes, and it is the only signal that can tell a confident agent from a
correct one. /dispute is the other half — when the agent heard "yes" and the customer
meant "maybe", telling us makes the next one better.
Get a key
Keys are issued from the dashboard under Developers. A key is shown once and
stored as a hash, so a leaked database is not a set of working keys — if you lose it, roll it.
Start with a ck_test_ key; nothing you do with it can ring a real phone.