Uploading audio
Request an upload, send the bytes, get a ticket.
Submitting a call takes two requests and a file transfer. Call Lens gives you a URL to write to, you send the recording straight to storage, and then you submit the call with the ticket you were given.
The recording never passes through the Call Lens API. That is the reason for the extra round trip: a 200 MB file crosses the network once instead of twice.
This runs as written, given CALLLENS_API_KEY, a call.mp3 beside it, and jq.
# 1 — ask for somewhere to write
AUTH=$(curl -sS -X POST https://api.calllens.io/api/v1/ingest/uploads \
-H "Authorization: Bearer $CALLLENS_API_KEY" \
-H 'Content-Type: application/json' \
-d "{\"content_type\":\"audio/mpeg\",\"bytes\":$(wc -c < call.mp3)}")
URL=$(printf '%s' "$AUTH" | jq -r '.data.upload.url')
TICKET=$(printf '%s' "$AUTH" | jq -r '.data.ticket')
# 2 — send the bytes straight to storage
curl -sS -X PUT "$URL" -H 'Content-Type: audio/mpeg' --data-binary @call.mp3
# 3 — submit the call
curl -sS -X POST https://api.calllens.io/api/v1/ingest/calls \
-H "Authorization: Bearer $CALLLENS_API_KEY" \
-H 'Content-Type: application/json' \
-d "{\"mode\":\"upload\",\"upload_ticket\":\"$TICKET\",\"started_at\":\"2026-08-05T09:14:00Z\",\"provider\":\"zoom\",\"provider_call_id\":\"CA123\",\"idempotency_key\":\"CA123\"}"Requesting an upload
POST /uploads needs the ingest scope.
| Field | Notes |
|---|---|
content_type | Required. One of the eleven types below — not a pattern. It also chooses the file extension Call Lens gives the object, which is why no filename is accepted. |
bytes | Required. The size of the file you are about to send, 1 to 209715200 (200 MB). |
sha256 | Optional. SHA-256 of the bytes, lowercase hex. |
Accepted content_type values: audio/mpeg, audio/mp4, audio/x-m4a, audio/aac, audio/wav,
audio/x-wav, audio/wave, audio/webm, audio/ogg, audio/flac, audio/x-flac.
{
"success": true,
"message": "Upload authorised.",
"data": {
"upload": {
"method": "PUT",
"url": "https://…?X-Amz-Signature=…",
"headers": { "Content-Type": "audio/mpeg" },
"expires_at": "2026-08-05T10:14:00Z"
},
"ticket": "eyJpdiI6…",
"allowance": { "limit": null, "used": 12, "remaining": null, "resets_at": null }
}
}allowance is your remaining ingestion allowance, returned on success as well as on refusal so you
can see the bound before you hit it. limit and remaining are null when the window is
unbounded.
Sending the bytes
upload.headers is the authoritative list, and it must be applied to the PUT verbatim. The
map is empty against some storage backends and non-empty against others. A client that ignores it
works perfectly in development and then fails with a signature mismatch in production — this is the
single most common mistake at this endpoint.
The step 2 example above hardcodes Content-Type because that is what the map contains today. If
you would rather not depend on that, apply whatever came back:
HEADERS=()
while IFS= read -r h; do HEADERS+=(-H "$h"); done < <(
printf '%s' "$AUTH" | jq -r '.data.upload.headers | to_entries[] | "\(.key): \(.value)"'
)
curl -sS -X PUT "$URL" "${HEADERS[@]}" --data-binary @call.mp3The URL accepts one PUT and expires after an hour. Nothing about the file is enforced by the
storage backend, so what you declared at step 1 bounds nothing: Call Lens measures the object
itself when you submit the call, and refuses it there.
sha256 is worth sending. It is sealed into the ticket at step 1 — before the transfer — so Call
Lens can verify the bytes that arrived are the bytes you meant to send. A hash accepted at step 3
would be computed from whatever landed and could only ever agree with itself.
The ticket
Opaque, sealed, and single-use in the sense that matters: it names one object and authorises one call. You cannot read it, edit it, or point it at a different recording, and it expires with the URL. Submitting the same ticket twice returns the same call rather than creating a second one.
If a ticket expires or is rejected, request a new upload. There is no way to renew one.
Submitting the call
See submitting a call for every field. The two that matter
here are mode: "upload" and upload_ticket.
Refusals
| Status | error | What it means |
|---|---|---|
401 | — | The API key is missing, malformed or revoked. |
403 | insufficient_scope | The key does not carry the ingest scope. |
422 | — | A field is invalid: an unaccepted content_type, a bytes outside the range, a malformed sha256. |
422 | upload_ticket_invalid | The ticket is unreadable, expired, or was not issued to you. Request a new upload. |
422 | upload_not_received | No file has arrived at that upload yet. Complete the PUT and submit again — the ticket is still good. |
422 | upload_too_large | The object exceeds 200 MB. It has been discarded; the same file will never be accepted. |
422 | upload_not_audio | The object does not begin like a recognised audio container. It has been discarded. |
402 | see ingestion refusals | Your allowance or subscription will not admit another call. The file is fine; retry when the window resets. |
429 | — | Too many upload requests. POST /uploads allows 10 a minute per key, separately from the plane's own budget. |
503 | upload_unsupported | This deployment's storage cannot presign uploads. An operator problem; retry after a deploy. |
The distinction worth coding against: 402 means the recording was acceptable and your account was
not, so retrying later works. The three upload_* codes mean the file itself will never be
accepted, and retrying is pointless.
Recordings longer than 60 minutes are refused later in the pipeline rather than at this door.