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.
POST /v1/recordingswith{ "content_type": "audio/mpeg", "size_bytes": 4821300 }. Chambr returns 201 withrecording_id,upload_url,upload_methodandupload_headers.PUTthe bytes toupload_urlwith exactly theupload_headers. Do not send anAuthorizationheader.- 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.