Call Lens
Ingesting calls

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.

FieldNotes
content_typeRequired. 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.
bytesRequired. The size of the file you are about to send, 1 to 209715200 (200 MB).
sha256Optional. 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.mp3

The 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

StatuserrorWhat it means
401The API key is missing, malformed or revoked.
403insufficient_scopeThe key does not carry the ingest scope.
422A field is invalid: an unaccepted content_type, a bytes outside the range, a malformed sha256.
422upload_ticket_invalidThe ticket is unreadable, expired, or was not issued to you. Request a new upload.
422upload_not_receivedNo file has arrived at that upload yet. Complete the PUT and submit again — the ticket is still good.
422upload_too_largeThe object exceeds 200 MB. It has been discarded; the same file will never be accepted.
422upload_not_audioThe object does not begin like a recognised audio container. It has been discarded.
402see ingestion refusalsYour allowance or subscription will not admit another call. The file is fine; retry when the window resets.
429Too many upload requests. POST /uploads allows 10 a minute per key, separately from the plane's own budget.
503upload_unsupportedThis 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.

On this page