Menu

Attach a recording

Upload an optional call recording for playback in Chambr, with a Node.js example.

On this page

A recording is for playback in Chambr. Chambr does not transcribe it; the transcript is still required. Upload from middleware: Apex heap and callout limits rule it out.

  1. POST /v1/recordings with { "content_type": "audio/mpeg", "size_bytes": 4821300 }. Chambr returns 201 with recording_id, upload_url, upload_method and upload_headers.
  2. PUT the bytes to upload_url with exactly the upload_headers. Do not send an Authorization header.
  3. Send the call with "recording_id" from step 1.
Rule Value
content_type audio/mpeg, audio/mp3, audio/wav, audio/x-wav, audio/mp4, audio/x-m4a, audio/aac, audio/webm, audio/ogg, audio/flac, video/mp4, video/webm, video/quicktime, video/x-matroska, video/x-m4v; else 422 recording_type_not_supported
Size 200 MB (209,715,200 bytes), checked on the uploaded file; else 422 recording_too_large
Upload URL Valid for 15 minutes
Use it Within 24 hours of uploading
Not uploaded, wrong id or another account's 422 recording_not_found; nothing is saved
Reuse One recording per call; 409 recording_already_used
Existing calls A recording cannot be added later; a resend ignores recording_id

Example: Node.js

const base = process.env.CHAMBR_API;               // https://api.staging.chambr.ai/v1
const auth = { Authorization: `Bearer ${process.env.CHAMBR_KEY}` };

async function sendCall(call, audioBuffer) {
  let recordingId;
  if (audioBuffer) {
    // 1. Ask for an upload URL.
    const r = await fetch(`${base}/recordings`, {
      method: "POST",
      headers: { ...auth, "Content-Type": "application/json" },
      body: JSON.stringify({ content_type: "audio/mpeg", size_bytes: audioBuffer.length }),
    });
    if (r.status !== 201) throw new Error(`recordings ${r.status} ${await r.text()}`);
    const upload = await r.json();

    // 2. PUT the bytes with exactly the given headers (no Authorization here).
    const put = await fetch(upload.upload_url, {
      method: upload.upload_method,
      headers: upload.upload_headers,
      body: audioBuffer,
    });
    if (!put.ok) throw new Error(`upload ${put.status}`);
    recordingId = upload.recording_id;
  }

  // 3. Send the call. Idempotent: safe to retry on 429 and 5xx.
  const res = await fetch(`${base}/calls`, {
    method: "POST",
    signal: AbortSignal.timeout(60_000),           // at least 60 s
    headers: { ...auth, "Content-Type": "application/json" },
    body: JSON.stringify({
      external_id: call.id,
      provider: "salesforce",
      occurred_at: call.endedAt,                   // RFC 3339, e.g. 2026-09-28T15:02:14Z
      call_type: call.type ?? null,
      rep: { email: call.repEmail, name: call.repName },
      participants: [{ role: "customer", name: call.buyerName, company: call.buyerCompany }],
      transcript: { segments: call.segments, rep_speaker: call.repSpeakerLabel },
      ...(recordingId ? { recording_id: recordingId } : {}),
      metadata: { salesforce: { task_id: call.id } },
    }),
  });
  // A proxy in between can answer with a bare status or HTML, so parse defensively.
  const body = await res.json().catch(() => null);
  if (res.status === 200 || res.status === 202) return body;  // body.app_url
  if (res.status === 429) throw Object.assign(new Error("rate limited"), {
    retryAfter: Number(res.headers.get("Retry-After")),
  });
  if (!body?.error) throw new Error(`${res.status} non-JSON response`);   // retry 5xx with backoff
  throw new Error(`${res.status} ${body.error.code} (${body.error.request_id}): ${body.error.message}`);
}

See Rate limits and retries for what to retry.