Developers
Dub videos from your own systems
The Metaphona REST API lets an LMS, a media library or a CMS upload videos, start dubbing, review the translated lines and download the dubbed videos, without the web app.
- REST and JSON
- Bearer API key
- 120 requests a minute
- Signed webhooks
Getting started
A plan with the API
The API comes with plans that include it. Without it, every call answers 403 api_not_in_plan.
Create a key
An owner or manager opens Your organization → API & webhooks, names the key and chooses its permissions. The whole key is shown once.
Call the API
Send the key in the Authorization header of every request to the base address below. Your first call can list your projects.
https://metaphona.devbuilder.dev/api/public/v1curl https://metaphona.devbuilder.dev/api/public/v1/projects \
-H "Authorization: Bearer $METAPHONA_API_KEY"Authentication
Every request carries an API key: Authorization: Bearer mph_<8 characters>_<43 characters>. The whole key is shown once, when it is created; only its SHA-256 is kept.
A key acts on behalf of the owner or manager who created it: it reaches what that person can reach. It stops working (401 api_key_invalid) when it is revoked, or when that person is removed, disabled, moves to another organization or is no longer an owner or manager. What it does appears in the activity log under their name.
No cookie and no CSRF token: the key is the only credential. Keep it on your server, never in a browser.
Permissions (scopes)
A key has one or more scopes and each route needs one. A missing scope answers 403 api_scope_missing.
- read12 routes
- See projects, videos, jobs, lines, voices and invoices.
- upload6 routes
- Create projects and upload videos.
- jobs6 routes
- Set a video’s languages and voices; start, cancel and retry jobs.
- review3 routes
- Edit and approve translated lines, or skip the review.
- export6 routes
- List dubbed videos, make download links, download videos and subtitles.
Limits and errors
120 requests a minute per key. Beyond that, 429 too_many_requests with a Retry-After header in seconds. Upload parts count like any request.
Errors are problem documents (application/problem+json, RFC 9457) with a stable code, a detail you can show to people, and a requestId to quote to support.
Ids of another organization answer exactly like ids that do not exist (404). Addresses that are not listed below answer 404 not_found.
Language codes come from GET /languages. Calls about one dub language take ?language=<code>; without it they mean the job’s primary language.
Codes you will meet
- unauthenticated
- No key was sent.
- api_key_invalid
- The key is unknown or revoked, or its creator can no longer use it.
- api_not_in_plan
- Your organization’s plan does not include the API.
- api_scope_missing
- The key lacks the scope this route needs.
- not_found
- Nothing with this id in your organization, or an address that is not part of the API.
- too_many_requests
- More than 120 requests in a minute: wait for Retry-After seconds.
Routes
All 33 routes, grouped by the scope they need. Paths are relative to the base address.
readSee projects, videos, jobs, lines, voices and invoices.
12 routesGET/languages
The languages and dubbing pairs your organization may use
Answers 200
GET/projects
Projects the key's creator can see (newest change first)
?status (optional)?query (optional)?page (optional)?size (optional)Answers 200
GET/projects/{projectId}
One project
Answers 200
GET/projects/{projectId}/media
The project's videos (newest first)
Answers 200
GET/projects/{projectId}/voices
The voices the project can use for one dub language, and the cloning models
?language (optional)Answers 200
GET/media/{mediaId}
One video, its check result, its languages and how far each dub language is
Answers 200
GET/media/{mediaId}/voice-settings
The voices used for one dub language of this video
?language (optional)Answers 200
GET/media/{mediaId}/jobs
The video's jobs (newest first)
Answers 200
GET/media/{mediaId}/translation
The latest job's translation of one dub language, in numbers (lines, approved, flagged)
?language (optional)Answers 200
GET/jobs/{jobId}
A job's state, progress, time estimate, stages and each dub language
Answers 200
GET/jobs/{jobId}/translations
The translated lines of one dub language, in order
?language (optional)?page (optional)?size (optional)Answers 200
GET/invoices/{invoiceId}/pdf
A sent invoice or credit note as PDF (the
invoice.sentevent links here)Answers 200
uploadCreate projects and upload videos.
6 routesPOST/projects
Create a project (for example one per course) with its languages
Body: application/jsonAnswers 201
POST/projects/{projectId}/media/uploads
Start a resumable upload
Body: application/jsonAnswers 201
Reserves the space (checked against your organization's storage quota and the longest video your plan allows once the file is checked). The answer says the part size and how many parts to send; parts can be sent in any order and again after an interruption (
GET /uploads/{uploadId}lists the parts already received).GET/uploads/{uploadId}
An upload's state and the parts already received (to resume)
Answers 200
DELETE/uploads/{uploadId}
Abandon an upload and free its reserved space
Answers 204
PUT/uploads/{uploadId}/parts/{partNumber}
Send one part (exactly partSize bytes; the last part may be shorter)
Body: application/octet-streamAnswers 200
POST/uploads/{uploadId}/complete
Finish the upload; the video is then checked (content, duration, tracks) in the background
Answers 202
jobsSet a video’s languages and voices; start, cancel and retry jobs.
6 routesPUT/projects/{projectId}/voice
The project's voice for one dub language (a standard voice, automatic voices, or cloned voices)
?language (optional)Body: application/jsonAnswers 200
Cloned voices (
cloneVoices: true) are only for recordings your organization holds the rights to: the first time, sendattestRights: true(recorded with the key creator's name and the time in the activity log). Cloning is not available on every plan and needs a cloning model that speaks the language.PUT/media/{mediaId}/languages
The video's own languages (otherwise the project's); applies to its next job
Body: application/jsonAnswers 200
PUT/media/{mediaId}/clone-engine
The cloning model of this video for one dub language (null = the project's)
?language (optional)Body: application/jsonAnswers 200
POST/media/{mediaId}/jobs
Start dubbing the video into its target languages
Body: application/jsonAnswers 201
The video must be
READY. Uses the video's languages (or the project's) and voices. Counts against your monthly minutes; refused (409/403 with a code) when a quota is reached, too many videos are being dubbed at once, or new videos are paused because an invoice is overdue.POST/jobs/{jobId}/cancel
Cancel a job (or one of its languages)
?language (optional)Answers 200
POST/jobs/{jobId}/retry
Retry the failed or cancelled steps (finished work is kept)
?language (optional)Answers 200
reviewEdit and approve translated lines, or skip the review.
3 routesPOST/jobs/{jobId}/translations/approve-all
Approve every line of one dub language; once all are approved, the voices are made
?language (optional)Answers 200
POST/jobs/{jobId}/skip-review
Go on without reviewing one dub language (the machine translation is used as it is)
?language (optional)Answers 200
PATCH/translations/{lineId}
Change one line's text and/or approve it
Body: application/jsonAnswers 200
exportList dubbed videos, make download links, download videos and subtitles.
6 routesGET/media/{mediaId}/exports
The dubbed videos made from this video (kept 30 days)
?language (optional)Answers 200
GET/media/{mediaId}/exports/multi
The state of the one-file export with every finished dub language
Answers 200
POST/media/{mediaId}/exports/multi
Make one video with every finished dub language as audio and subtitle tracks
Body: application/jsonAnswers 202
GET/media/{mediaId}/subtitles
Subtitles of one dub language (SRT or WebVTT)
?language (optional)?format (optional)Answers 200
POST/exports/{exportId}/link
A download link for a dubbed video (valid 15 minutes, usable only with the same API key's creator)
Answers 200
GET/downloads/{token}
The dubbed video file (supports Range requests to resume)
Answers 200
Webhooks
Owners and managers add https addresses on Your organization → API & webhooks and choose the events. Each event is a POST of JSON to your address.
Events
- job.waiting_review
A dub language waits for its translation review.
- job.done
A job finished: every dub language has its video.
- job.failed
A job stopped with an error; it can be retried.
- invoice.sent
An invoice was sent to your organization.
webhook.test is sent by “Send test” to check your address.
What is sent
Payloads hold ids, languages, states and links only: never transcript text or file contents.
{
"id": "6f1c2a4e-…",
"type": "job.done",
"createdAt": "2026-11-09T10:00:00Z",
"organizationId": "…",
"data": {
"jobId": "…", "mediaId": "…", "projectId": "…",
"sourceLanguage": "<code>", "language": "<code>", "status": "SUCCEEDED",
"languages": [{ "language": "<code>", "status": "SUCCEEDED" }],
"links": {
"job": "https://metaphona.devbuilder.dev/api/public/v1/jobs/…",
"exports": "https://metaphona.devbuilder.dev/api/public/v1/media/…/exports?language=<code>"
}
}
}Headers
- X-Metaphona-Event
- The event type, the same as type in the body.
- X-Metaphona-Delivery
- The delivery id. It stays the same when a delivery is retried or redelivered: store the ids you handled and ignore repeats.
- X-Metaphona-Signature
- t=<unix seconds>,v1=<hex HMAC-SHA256>, made with your signing secret.
Check the signature
Compute an HMAC-SHA256 with your signing secret (whsec_…, shown once) over t + "." + the exact bytes received. Compare it with v1 in constant time, and refuse a t more than 5 minutes away from your clock.
import hashlib, hmac, time
def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
"""header: X-Metaphona-Signature; body: the exact bytes received."""
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
t = int(parts.get("t", "0"))
if abs(time.time() - t) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))import { createHmac, timingSafeEqual } from 'node:crypto'
// header: X-Metaphona-Signature; body: the raw request body (a Buffer, as received).
export function verify(secret, header, body, tolerance = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=', 2)))
const t = Number(parts.t)
if (!t || Math.abs(Date.now() / 1000 - t) > tolerance) return false
const expected = createHmac('sha256', secret).update(`${t}.`).update(body).digest('hex')
const given = Buffer.from(parts.v1 ?? '')
return given.length === expected.length && timingSafeEqual(given, Buffer.from(expected))
}Answer quickly
Answer any 2xx within 10 seconds and do the work afterwards. Any other answer, or none, is retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours; then the delivery is marked failed and can be sent again from the delivery log.
Example: an LMS
A course platform (Moodle, Canvas or your own) sends a lesson video and gets the dubbed version back. These are the calls it makes, in order.
Find or create the course’s project
Scope:readuploadOne project per course keeps its videos, glossary and voices together.
- GET /projects?query=…
- POST /projects
Upload the video in parts
Scope:uploadThe answer gives the part size and the number of parts. Send them in any order; after an interruption, GET the upload to see which parts arrived.
- POST /projects/{projectId}/media/uploads
- PUT /uploads/{uploadId}/parts/{n}
- POST /uploads/{uploadId}/complete
Wait for the check
Scope:readThe video is checked in the background: wait until its status is READY.
- GET /media/{mediaId}
Start dubbing
Scope:jobsOptionally set this video’s own languages first. The job’s minutes count against your monthly minutes.
- PUT /media/{mediaId}/languages
- POST /media/{mediaId}/jobs
Follow the job
Scope:readAsk for the job’s state, or let the webhooks tell you: job.waiting_review, job.done, job.failed.
- GET /jobs/{jobId}
Review, or skip the review
Scope:readreviewRead the translated lines, fix some, then approve them all, or go on with the machine translation.
- GET /jobs/{jobId}/translations?language=…
- PATCH /translations/{lineId}
- POST /jobs/{jobId}/translations/approve-all?language=…
- POST /jobs/{jobId}/skip-review?language=…
Download the dubbed video
Scope:exportEach download link is valid 15 minutes and supports Range requests to resume. Dubbed videos are kept 30 days: copy them into your own storage.
- GET /media/{mediaId}/exports?language=…
- POST /exports/{exportId}/link
- GET <link>
If it failed
Scope:jobsRetry: the finished steps are kept.
- POST /jobs/{jobId}/retry
In an LMS
- Store the key and the webhook secret in the LMS’s encrypted settings, never in course content or in the browser.
- Keep the project id with the course, and the video and job ids with the lesson.
- On job.done, download the dubbed file and attach it to the lesson as its other-language version, or link the WebVTT subtitles.
OpenAPI file
The full description of the API, OpenAPI 3.1: open it in Swagger UI or Postman, or generate a client from it. It is public: no key is needed to download it.
Metaphona public API · 1.0 · https://metaphona.devbuilder.dev/api/public/v1