# Attach a recording

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

```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](https://developers.staging.chambr.ai/rate-limits.md) for what to retry.
