Import Call Recordings and Transcripts¶
The Partner API accepts a single multipart upload that contains either a text transcript or an audio recording and attaches the result to a Contio meeting. The API decides which pipeline to run from the uploaded file's extension:
- Text files (
.txt,.md,.srt,.vtt,.pdf) are parsed synchronously and return HTTP 200 with the created transcript. - Audio files are transcribed asynchronously and return HTTP 202 with a job you poll for completion.
This guide covers the multipart contract, bound vs. unbound imports, polling audio jobs, error handling, limits, and SDK usage.
Recording consent
Contio does not verify that participants consented to being recorded. The partner application that uploads a recording is responsible for obtaining and retaining any consent required by applicable law before importing it.
Endpoints¶
| Operation | Method and path | Scope | Success |
|---|---|---|---|
| Import into an existing meeting | POST /v1/partner/user/meetings/{id}/transcript/import | meetings:write | 200 (text) / 202 (audio) |
| Import and create a meeting | POST /v1/partner/user/transcript-imports | meetings:write | 200 (text) / 202 (audio) |
| Read audio job status | GET /v1/partner/user/transcript-imports/audio/jobs/{jobId} | meetings:read or meetings:write | 200 |
All three operations are Partner User API operations and require an OAuth access token. Full request/response schemas are in the OpenAPI Specification under importMeetingTranscript, importTranscript, and getAudioTranscriptImport.
Dispatch flow¶
flowchart TD
A[POST multipart/form-data with file] --> B{File extension}
B -->|.txt .md .srt .vtt .pdf| C[Parse transcript]
C --> D[200 kind=text<br/>transcript_id, segments_count]
B -->|.mp3 .m4a .wav ...| E[Plan + credit eligibility]
E -->|ineligible| F[402 feature_not_available / 402 insufficient_credits]
E -->|eligible| G[Create audio job]
G --> H[202 kind=audio<br/>job_id, state=pending]
H --> I["GET .../audio/jobs/{jobId}"]
I -->|state != completed/failed| I
I -->|completed| J[meeting_id, transcript_id]
I -->|failed| K[failure_code]
B -->|anything else| L[400 unsupported_format] Supported file types¶
| Kind | Extensions |
|---|---|
| Text | .txt, .md, .srt, .vtt, .pdf |
| Audio | .mp3, .m4a, .mp4, .wav, .aac, .ogg, .oga, .opus, .flac, .webm, .amr, .aiff, .wma |
PDF imports use the document's text layer only; scanned or image-only PDFs contain no extractable text and are rejected with 400 invalid_request.
Dispatch is by extension only. A .mp4 or .webm container is accepted as audio; if it contains no audio track the job fails later with video_no_audio. Any other extension is rejected with 400 unsupported_format before anything is stored.
Request format¶
Both POST endpoints accept multipart/form-data.
| Field | Type | Applies to | Description |
|---|---|---|---|
file | file | both | Required. The transcript or recording. The filename extension selects the pipeline. |
meeting_id | string | /transcript-imports only | Optional. When present the import is bound to this existing meeting (equivalent to the /meetings/{id}/transcript/import route). When absent a meeting is created for the import. |
title | string | unbound | Optional meeting title. |
starts_at | RFC 3339 timestamp | unbound | Optional meeting start time. |
duration_seconds | integer ≥ 0 | unbound | Optional meeting duration. |
participants | string (repeatable) | unbound | Optional participant names or emails. Repeat the field once per participant. |
calendar_event_id | string | audio, unbound | Optional calendar event to bind the created meeting to. |
title, starts_at, duration_seconds, and participants are ignored for bound imports because the target meeting already has that metadata.
Bound vs. unbound imports¶
- Bound — the meeting already exists in Contio. Use
POST /meetings/{id}/transcript/import, orPOST /transcript-importswithmeeting_id. The meeting must not have started, must not be finalized, and must not already hold a transcript longer than two minutes; otherwise the request returns409 conflict. - Unbound — no meeting exists yet. Use
POST /transcript-importswithoutmeeting_id. Contio creates a meeting from the optional metadata (or fromcalendar_event_idfor audio) and attaches the transcript to it. The createdmeeting_idis returned in the text response, or on the audio job once it reachescompleted.
Text imports (synchronous)¶
Text uploads are parsed in the request and return 200 OK with kind: "text":
{
"kind": "text",
"meeting_id": "mtg_01J8...",
"transcript_id": "rm_01J8...",
"segments_count": 142,
"duration_seconds": 1860,
"started_at": "2026-09-16T14:00:00Z",
"ended_at": "2026-09-16T14:31:00Z"
}
curl -X POST "https://api.contio.ai/v1/partner/user/meetings/mtg_01J8.../transcript/import" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-F "file=@call.vtt"
Text imports are not plan-gated and do not consume credits.
Audio imports (asynchronous)¶
Audio uploads are staged, checked for plan and credit eligibility, and queued. The response is 202 Accepted with kind: "audio":
{
"kind": "audio",
"job_id": "aij_01J8...",
"state": "pending",
"created_at": "2026-09-16T14:32:10Z",
"updated_at": "2026-09-16T14:32:10Z"
}
curl -X POST "https://api.contio.ai/v1/partner/user/transcript-imports" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-F "file=@call.m4a" \
-F "title=Q3 pipeline review" \
-F "starts_at=2026-09-16T14:00:00Z" \
-F "participants=alice@example.com" \
-F "participants=bob@example.com"
Job states¶
Poll GET /v1/partner/user/transcript-imports/audio/jobs/{jobId} until the job reaches a terminal state.
| State | Terminal | Meaning |
|---|---|---|
pending | no | Queued, waiting for a worker. |
acquiring | no | Worker is reading the staged upload. |
validating | no | Probing the container, duration, and size. |
transcribing | no | Speech-to-text in progress. |
attaching | no | Binding the meeting and writing the transcript. |
completed | yes | meeting_id and transcript_id are populated. |
failed | yes | failure_code explains why. |
curl "https://api.contio.ai/v1/partner/user/transcript-imports/audio/jobs/aij_01J8..." \
-H "Authorization: Bearer $ACCESS_TOKEN"
{
"kind": "audio",
"job_id": "aij_01J8...",
"state": "completed",
"meeting_id": "mtg_01J8...",
"transcript_id": "rm_01J8...",
"created_at": "2026-09-16T14:32:10Z",
"updated_at": "2026-09-16T14:41:52Z",
"completed_at": "2026-09-16T14:41:52Z"
}
Transcription time scales with recording length. Poll every 5–15 seconds and apply an overall timeout on your side; a one-hour recording can take several minutes. Jobs that exceed the server-side wall-clock limit are failed with timeout.
Failure codes¶
When state is failed, failure_code is one of the values below.
Non-exhaustive
This table lists the codes the import pipeline emits today. New codes may be added without a version bump; treat unknown values as a generic failure and surface the code to your support tooling.
failure_code | Stage | Meaning / what to do |
|---|---|---|
unsupported_format | validating | Container or codec is not supported. Re-encode (for example to MP3 or M4A) and resubmit. |
container_mismatch | validating | File contents do not match the extension. Fix the filename or re-export the file. |
video_no_audio | validating | A video container with no audio track. |
empty_or_corrupt | validating | The file is empty or could not be decoded. |
zero_duration | validating | Decodable but contains no audio samples. |
too_large | validating | Exceeds the size limit (see Limits). |
too_long | validating | Exceeds the duration limit (see Limits). |
source_not_found | acquiring | The staged upload expired or was removed before a worker picked it up. Resubmit. |
acquire_failed | acquiring | Could not read the staged upload. Retry later. |
provider_access_denied | acquiring | Storage provider rejected the read. Not retryable by the partner; contact support. |
invalid_input | acquiring / attaching | The job was submitted with inconsistent metadata. Check the form fields and resubmit. |
stt_unavailable | transcribing | Speech-to-text provider unavailable. Retry later. |
stt_session_invalid | transcribing | The transcription session was rejected by the provider. Retry later. |
transcription_failed | transcribing | Transcription produced no usable output. |
timeout | any | The job exceeded the server-side wall-clock limit. |
bind_failed | attaching | An unbound import could not be bound to or create a meeting (for example an invalid calendar_event_id). |
eligibility_error | attaching | Eligibility could not be re-evaluated when attaching. Retry later. |
conflict_meeting_already_started, conflict_meeting_finalized, conflict_transcript_exists, conflict_plan_required, conflict_no_credits | attaching | The meeting became ineligible between submission and attach (someone started it, it was finalized, another transcript landed, or the plan/credits changed). |
conflict_late_eligibility | attaching | The transcript could not be attached because the meeting's eligibility changed at the last moment. |
attach_failed | attaching | Writing the transcript failed. Retry later. |
internal_error | any | Unexpected error. Retry later; contact support with the job_id if it persists. |
Errors¶
Except for 402, errors use the standard PartnerErrorResponse envelope (error, code, request_id).
| Status | code | When |
|---|---|---|
400 | invalid_request | Missing file, malformed starts_at, negative duration_seconds, an invalid meeting_id, or a file that could not be parsed (e.g. an image-only PDF with no text layer). |
400 | unsupported_format | Extension is neither a text nor an audio type. |
401 | unauthorized | Missing or expired access token. |
402 | feature_not_available | Audio only. The workspace plan does not include AUDIO_IMPORT (Elite). Body is the FeatureNotAvailableResponse envelope with feature, required_plan, and current_plan. |
402 | insufficient_credits | Audio only. The workspace has no remaining credits. |
403 | forbidden | Token lacks meetings:write. |
404 | not_found | The meeting or job does not exist or is not accessible to the token's user, or transcript import is not enabled for the workspace. |
409 | conflict | The meeting has already started, is finalized, or already has a transcript longer than two minutes. |
413 | request_too_large | The audio upload exceeds the audio size limit, or a text/PDF upload exceeds the transcript size or parsed-output limits. |
500 | internal_error | Unexpected server error. |
Two 402 shapes
A 402 from an audio upload is either the plan gate (code: "feature_not_available", FeatureNotAvailableResponse envelope) or an empty credit balance (code: "insufficient_credits", standard PartnerErrorResponse). Branch on code, not on the status alone.
Retries and idempotency¶
Multipart endpoints are excluded from the Idempotency-Key mechanism described in Idempotency. Retrying a transcript import is not deduplicated by the server:
- Retrying a text import into the same meeting returns
409 conflictonce the first attempt has stored a transcript longer than two minutes. - Retrying an audio import creates a new job each time. If the first job is still running, both jobs proceed and the second one fails at the attach stage with
conflict_transcript_existsorconflict_late_eligibility; if the first job already completed, the second submission returns409 conflict.
Recommended retry pattern:
- Submit the upload once and persist the returned
job_id(audio) ortranscript_id(text) alongside your own record. - If the response was lost (timeout, connection reset), do not blindly resubmit. For audio, call
getAudioTranscriptImportwith the storedjob_id; for either kind, check whether the meeting already has a transcript before uploading again. - Treat
409 conflicton a retry as success of the earlier attempt, not as an error.
Limits¶
| Limit | Default |
|---|---|
| Maximum audio upload size | 2 GiB (2147483648 bytes) — enforced as 413 request_too_large on upload and too_large during validation |
| Maximum audio duration | 3 hours (10800 seconds) — enforced as too_long during validation |
| Maximum text/PDF transcript upload size | 50 MiB (52428800 bytes, TRANSCRIPT_IMPORT_MAX_BYTES) — enforced as 413 request_too_large |
| Maximum PDF pages | 500 pages — enforced as 413 request_too_large |
| Maximum parsed transcript output | 50,000 segments / 10 MiB of transcript text — enforced as 413 request_too_large |
Limits are configured per environment and may differ for your partner agreement.
Plan gating and rollout¶
- Text import is available on every plan.
- Audio import requires the
AUDIO_IMPORTfeature, which is included from the Elite plan tier. Lower tiers receive402 feature_not_availablewith theFeatureNotAvailableResponseenvelope at submission time. Audio transcription also consumes workspace credits; a workspace with no balance receives402 insufficient_credits. - Transcript import is rolled out per workspace behind a feature flag. Where the flag is disabled the endpoints respond
404 not_found. Contact your partner manager if a workspace you expect to be enabled returns404.
SDK usage¶
Requires @contio/partner-sdk 1.12.0
The methods below ship in SDK 1.12.0. Earlier versions can still call the endpoints through the generic transport, but the typed helpers and waitForAudioTranscriptImport are not available.
import { readFile } from 'node:fs/promises';
import { ContioPartnerSDK, ContioAPIError } from '@contio/partner-sdk';
const sdk = ContioPartnerSDK.forUser({ clientId, clientSecret, redirectUri });
// ...complete the OAuth flow and store tokens...
const user = sdk.user;
// Text: synchronous 200
const vtt = new Blob([await readFile('call.vtt')], { type: 'text/vtt' });
const text = await user.importMeetingTranscript('mtg_01J8...', {
file: vtt,
filename: 'call.vtt',
});
if (text.kind === 'text') {
console.log(`Imported ${text.segments_count} segments into ${text.meeting_id}`);
}
// Audio: 202 + poll
const audio = new Blob([await readFile('call.m4a')], { type: 'audio/mp4' });
const accepted = await user.importTranscript({
file: audio,
filename: 'call.m4a',
title: 'Q3 pipeline review',
starts_at: '2026-09-16T14:00:00Z',
participants: ['alice@example.com', 'bob@example.com'],
});
if (accepted.kind === 'audio') {
try {
const job = await user.waitForAudioTranscriptImport(accepted.job_id, {
intervalMs: 10_000,
timeoutMs: 20 * 60_000,
});
if (job.state === 'completed') {
console.log(`Transcript ${job.transcript_id} attached to ${job.meeting_id}`);
} else {
console.error(`Import failed: ${job.failure_code}`);
}
} catch (err) {
if (err instanceof ContioAPIError) {
console.error(err.statusCode, err.code, err.message);
} else {
throw err; // polling timed out; the job may still complete server-side
}
}
}
waitForAudioTranscriptImport polls getAudioTranscriptImport until the job is completed or failed, and rejects with an error when timeoutMs elapses first. A timeout does not cancel the job — store the job_id and check it again later.
See SDK Examples for authentication setup and the Agent Integration guide for the equivalent MCP tool definitions (importMeetingTranscript, importTranscript, getAudioTranscriptImport).
Next steps¶
- Idempotency — why multipart uploads are excluded and how to retry safely
- Feature Gating — plan-gated endpoints and the
402envelope - Webhook Events — react to meeting and transcript lifecycle events instead of polling
- Release Notes — what shipped in v1.12