Recordable Call

Webhook reference

When notes are written for one of your calls, we POST them to the URL you configured. This page is what you need to receive that at your own endpoint — the payload, the signature, and what happens when things go wrong.

The payload

Content-Type is application/json. The object is flat on purpose: automation platforms map by field name, and nesting the useful values would make every integration start with a reshaping step.

{
  "event": "note.created",
  "call_id": "cl9x2k4p00001",
  "caller_number": "+15551234567",
  "started_at": "2026-01-02T15:04:05.000Z",
  "duration_seconds": 214,
  "template": "SALES_CALL",
  "template_label": "Sales call",
  "note": {
    "summary": "Walked through pricing for the team plan...",
    "actionItems": [
      { "owner": "you", "task": "Send the security questionnaire" }
    ],
    "objections": ["Wants SSO before rolling out to the whole team"]
  },
  "note_text": "Walked through pricing for the team plan...\n\nACTION ITEMS\n...",
  "transcript": "You: Thanks for making the time...\n\nOther party: ...",
  "download_url": "https://recordablecall.com/download?phone=15551234567&code=A1B2C3",
  "model": "claude-opus-5"
}

note is shaped by the template — a sales call has objections, an interview has questions asked. note_text is the same content rendered as prose, and is the field to use if you only want one.

Headers

HeaderMeaning
X-RecordableCall-SignatureHMAC-SHA256 over `timestamp.body`, hex encoded, using your signing secret.
X-RecordableCall-TimestampUnix seconds at the moment we signed. Reject anything much older to stop replays.
X-RecordableCall-Event-IdStable across retries of the same note. Store it and skip repeats.

Verifying the signature

Sign over the raw request body, not a re-serialised object. Parsing and re-encoding JSON changes whitespace and key order, and the signature will never match.

Node

import crypto from "node:crypto";

// Express, with the raw body preserved:
//   app.use("/hooks/calls", express.raw({ type: "application/json" }))
app.post("/hooks/calls", (req, res) => {
  const timestamp = req.header("X-RecordableCall-Timestamp");
  const signature = req.header("X-RecordableCall-Signature");
  const body = req.body.toString("utf8");

  const expected = crypto
    .createHmac("sha256", process.env.RECORDABLECALL_SECRET)
    .update(`${timestamp}.${body}`)
    .digest("hex");

  // Constant-time: a fast reject on a near-miss leaks the secret byte by byte.
  const ok =
    signature?.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));

  // Reject anything older than five minutes, so a captured delivery cannot be
  // replayed later.
  const fresh = Math.abs(Date.now() / 1000 - Number(timestamp)) < 300;

  if (!ok || !fresh) return res.status(401).send("bad signature");

  const note = JSON.parse(body);
  // ... do something with note.note_text
  res.sendStatus(200);
});

Python

import hmac, hashlib, time
from flask import Flask, request, abort

@app.post("/hooks/calls")
def hook():
    timestamp = request.headers.get("X-RecordableCall-Timestamp", "")
    signature = request.headers.get("X-RecordableCall-Signature", "")
    body = request.get_data(as_text=True)

    expected = hmac.new(
        SECRET.encode(),
        f"{timestamp}.{body}".encode(),
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(signature, expected):
        abort(401)
    if abs(time.time() - int(timestamp or 0)) > 300:
        abort(401)

    note = request.get_json()
    # ... do something with note["note_text"]
    return "", 200

Your signing secret is shown once, on the settings page, right after you save a generic destination. Slack and Discord don't verify signatures, so we don't show a secret for those.

Retries and failures

  • Three attempts at most, spaced one second and three seconds apart.
  • 5xx and 429 are retried. A 4xx is not — the receiver understood and refused, so sending it again changes nothing.
  • Ten seconds per attempt. Respond first, process afterwards.
  • Redirects are not followed, and count as a failure.
  • Ten consecutive failures switches the destination off. The settings page shows the last status and error so you can see what happened; saving again turns it back on.
  • https only, to a hostname that resolves to a public address. A call recording is a private conversation and we will not send one in the clear.

Questions

When does a delivery fire?
When notes are written for a call on your number — either because you asked for them on the recording page, or because the background job got there first. One delivery per set of notes.
What happens if my endpoint is down?
We retry twice more, after one second and three seconds, on a 5xx or a 429. A 4xx is treated as a refusal and not retried, since sending the same thing again would change nothing. After ten consecutive failures the destination is switched off and the settings page tells you why.
Will I ever get the same note twice?
It's possible — if your endpoint accepts a delivery but the response is lost, we count that as a failure and retry. Every delivery carries an X-RecordableCall-Event-Id that is stable across retries, so store it and skip repeats.
How long do you wait for a response?
Ten seconds per attempt. Do the slow part after you respond — accept the delivery, return 200, then process.
Do you follow redirects?
No. A redirect is treated as a failed delivery. The destination is checked before every attempt to make sure it resolves to a public address, and following redirects would let a checked hostname hand the request somewhere unchecked.
Is the recording itself sent?
No — the notes, the transcript and a link. The audio stays behind your download code, so anyone who receives a delivery still cannot listen to the call without it.

Prefer not to write any code?

Slack and Discord need only a URL, and Zapier or Make will forward these deliveries into most things without an endpoint of your own.

See all integrations