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

bash
npm install @callex/node
typescript
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

bash create a lead and dial 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.

json intent.captured
{
  "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>".

javascript
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 & pathWhat it does
GET /v1/pingCheck a key and see which mode it is in
POST /v1/leadsCreate a contact, optionally dial it now
GET /v1/leads/{id}One contact
POST /v1/callsPlace a call
GET /v1/callsList calls, cursor paginated
GET /v1/calls/{id}One call, with its transcript
GET /v1/intentsWhat the agent understood, across calls
POST /v1/intents/{id}/outcomeWrite back what actually happened
POST /v1/intents/{id}/disputeTell us the agent read it wrong
POST /v1/destinationsCreate a webhook or REST destination
POST /v1/destinations/{id}/testSend a specimen event to it
GET /v1/deliveriesEvery attempt, with request and response
POST /v1/deliveries/{id}/replaySend 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.

Get a key Read the voice notes