HARMAR API
Word-level timestamped transcription for Armenian speech — including Armenian/Russian/English code-switching — as an async REST API. Timed words, sentence segments, plain text, SRT and VTT. Or skip timestamps and use it as a plain Armenian speech-to-text API.
Keys and prepaid minutes live at harmar.ai/app/api. Your first API key includes 10 free trial minutes.
https://api.harmar.aiQuickstart
The full loop in four calls: create an upload, PUT the file, submit the job, poll the result. Jobs run asynchronously — a one-minute video is typically ready in under a minute.
# 1 — create an uploadcurl -X POST https://api.harmar.ai/v1/uploads \-H "Authorization: Bearer hk_live_..." \-H "Content-Type: application/json" \-d '{ "filename": "reel.mp4", "file_size": 12345678 }'# 2 — PUT the file to the returned upload_urlcurl -X PUT "<upload_url>" \-H "Content-Type: video/mp4" \--data-binary @reel.mp4# 3 — submit the jobcurl -X POST https://api.harmar.ai/v1/transcripts \-H "Authorization: Bearer hk_live_..." \-H "Content-Type: application/json" \-d '{ "media_id": "<media_id>" }'# 4 — poll the resultcurl https://api.harmar.ai/v1/transcripts/<media_id> \-H "Authorization: Bearer hk_live_..."
CLI, MCP & agents
The same four calls, packaged: the harmar-ai npm package is a CLI for scripts and a Model Context Protocol server for AI coding agents (Claude Code, Claude Desktop, Cursor, Windsurf). Set HARMAR_API_KEY and it needs no install. The package also ships a SKILL.md that teaches an agent the whole flow.
export HARMAR_API_KEY=hk_live_...# identify the language, write an SRTnpx -y harmar-ai transcribe reel.mp4 --lang auto --srt --out reel.srt# Russian speech + an Armenian subtitle track, as VTTnpx -y harmar-ai transcribe talk.mp4 --lang ru --translate-to hy --vttnpx -y harmar-ai languages # what --lang accepts, livenpx -y harmar-ai balance # minutes left
No terminal at all: Harmar is also a remote connector. In claude.ai (or Claude Desktop / mobile) open Settings → Connectors → Add custom connector, paste the URL below, sign in with your Harmar account, and ask Claude to caption a video in your style. It works on your own plan — your videos, your minutes, your saved styles.
https://api.harmar.ai/mcp
For developers, the same tools as a local MCP server — one line for Claude Code, or the same in any mcp.json:
claude mcp add harmar -e HARMAR_API_KEY=hk_live_... -- npx -y harmar-ai mcp
{"mcpServers": {"harmar": {"command": "npx","args": ["-y", "harmar-ai", "mcp"],"env": { "HARMAR_API_KEY": "hk_live_..." }}}}
Authentication
Every request carries your secret key as a Bearer token. Keys are shown once at creation — store them server-side, never in client code. Manage keys at /app/api.
Authorization: Bearer hk_live_...
1 · Create an upload
/v1/uploadsReturns a signed URL you PUT the raw file bytes to (valid 30 minutes), plus the media_id you'll submit. Formats: MP4, MOV, WebM, M4A, MP3, WAV. Max 5 GB, max 60 minutes.
filenamestring required | File name with extension — determines the media type (mp4, mov, webm, m4a, mp3, wav). |
file_sizeinteger required | Exact size in bytes. Max 5 GB. |
POST /v1/uploads{ "filename": "reel.mp4", "file_size": 12345678 }
{"media_id": "9b2f7c1e-…","upload_url": "https://…r2.cloudflarestorage.com/…", // signed PUT, 30 min"content_type": "video/mp4","expires_in_seconds": 1800}
Then upload the bytes — Content-Type must match exactly what the response returned:
curl -X PUT "<upload_url>" -H "Content-Type: video/mp4" --data-binary @reel.mp4
2 · Submit a transcript
/v1/transcriptsStarts the job. Billing is per second of media, charged up front from your prepaid balance and refunded in full if the job fails.
All options shape the OUTPUT of the same pipeline run — one job, one price, any combination:
media_idstring required | The id returned by POST /v1/uploads (after the PUT finished). |
webhook_urlstring | HTTPS URL we POST to on completion or failure. Signed — see Webhooks. |
script_textstring | Align-only mode: you supply the ground-truth text, we compute only the timing. Max 100k chars. |
source_langISO 639-1 code default: "hy" | Optional — which language the media is spoken in. Defaults to Armenian. Any language listed by GET /v1/languages is accepted, or "auto" to identify it from the audio before transcription (the answer is echoed as detected_lang within seconds). Independent of translate_to — you can request a non-Armenian source with no translation at all, for plain transcription and timing in that language. |
keep_mediaboolean default: false | Keep the source media after transcription so the job can be exported as a captioned video (see Captioned video). Off by default — the retention promise is that media goes the moment the transcript exists. |
translate_to"ru" | "en" | "hy" | Optional — the target language for a translated subtitle track alongside the original, at no extra charge (included in the per-minute price). Armenian source media (the default) can translate into "ru" or "en"; non-Armenian source media (set source_lang) can translate into "hy". One target language per job. |
options.timestamps"word" | "segment" | "none" default: "word" | word → per-word + per-sentence timing; segment → sentences only; none → plain text only. |
options.punctuationboolean default: true | false strips punctuation from the output (numbers like 3.5 and words like ChatGPT-ը stay intact). |
options.speakersboolean default: true | Dialogue dashes on speaker changes + speaker ids on words. false removes the dashes; ids stay. |
options.lyrics"exclude" | "include" default: "exclude" | Sung lyrics are transcribed for timing accuracy but excluded from output by default; include returns them flagged is_lyric. |
POST /v1/transcripts{"media_id": "9b2f7c1e-…","webhook_url": "https://yourapp.com/hooks/harmar","options": {"timestamps": "word","punctuation": true,"speakers": true,"lyrics": "exclude"}}
// 202 Accepted{"id": "9b2f7c1e-…","status": "processing","duration_seconds": 61.4,"seconds_charged": 62}
3 · Fetch the result
/v1/transcripts/{id}Poll until status becomes completed or failed (or use a webhook). Completed responses carry the transcript in every enabled shape.
{"id": "9b2f7c1e-…","status": "completed","quality": "ok","duration_seconds": 61.4,"seconds_charged": 62,"text": "Բարև ձեզ։ ChatGPT-ը լավ tool ա։","words": [{ "text": "Բարև", "start": 0.42, "end": 0.81 },{ "text": "ChatGPT-ը", "start": 1.02, "end": 1.63, "speaker": 1 }],"segments": [{ "text": "Բարև ձեզ։", "start": 0.42, "end": 0.97 }],"srt_url": "/v1/transcripts/9b2f7c1e-…/srt","vtt_url": "/v1/transcripts/9b2f7c1e-…/vtt"}
Response fields
status"processing" | "completed" | "failed" | Also "awaiting_upload" before the media PUT completes. |
progressinteger 0–100 | Present while processing — real pipeline progress, good for a progress bar. |
media_retainedboolean | True while the source media is still stored (keep_media jobs) — an export is possible. |
export{ status } | Present once an export has been requested: "queued" | "rendering" | "completed" | "failed". The full state is GET /v1/transcripts/{id}/export. |
detected_langISO 639-1 code | Present when source_lang was "auto", from the first seconds of processing — the language the audio was identified as. Shown even when the answer is the default "hy". |
quality"ok" | "degraded" | degraded = timing confidence reduced in some sections; words are still correct. |
textstring | Full transcript, one sentence per line. |
words[]{ text, start, end, speaker?, is_lyric? } | Word-level timing in seconds. Present when timestamps = "word". |
segments[]{ text, start, end, speaker?, is_lyric? } | Sentence-level timing. Present unless timestamps = "none". |
srt_url / vtt_urlstring | Paths to ready subtitle files (same Bearer auth). |
source_lang"ru" | "en" | Present only when a non-default source_lang was requested at creation (never shown for the default "hy"). Echoed on every status, not only completed. |
translate_to"ru" | "en" | "hy" | Present when the job requested translation. Echoes the target language you chose at creation — on every status, not only completed. |
translation{ text, words?, segments? } | Present when translate_to was set and translation succeeded. Same shape as the top-level fields above (text / words / segments), in the target language — the same options (timestamps, punctuation, speakers, lyrics) apply to it. Word timing is inherited from the source track's timing, not independently aligned. |
translation_status"failed" | Present instead of translation when translate_to was set but the translation attempt did not succeed. Rare — the transcript itself still completes normally either way. |
seconds_chargedinteger | What this job cost your balance (= media duration rounded up). |
errorstring | Present on failed jobs. The charge is refunded automatically. |
Subtitle files
/v1/transcripts/{id}/srt/v1/transcripts/{id}/vttReady-made subtitle files, one cue per sentence segment. Same auth, plain-text responses:
?langISO 639-1 code | Query parameter. Omit for the SOURCE track (whatever language the media was in — "hy" by default, or your source_lang if you set one). Name the source or translate_to language explicitly to fetch that specific track — only when the job requested it and it completed. |
100:00:00,420 --> 00:00:00,970Բարև ձեզ։200:00:01,020 --> 00:00:02,180ChatGPT-ը լավ tool ա։
Captioned video (styled export)
Burn styled subtitles into the video and get an MP4 — no watermark, up to 1080p, up to 60 minutes. The transcript must have been submitted with keep_media: true (by default the source is deleted the moment the transcript exists — see Data retention). Charged per second of media at the transcription rate, refunded if the render fails. One export at a time per account; it queues behind the app's paying users.
/v1/styles/v1/style-presets/v1/style-presets/v1/style-presets/{id}A style is a JSON object — GET /v1/styles lists the seven caption presets, every font with the languages it actually renders, each field with its range, and the defaults per orientation. Save a style once under a name (POST /v1/style-presets, up to 10) and export by style_preset_id so every video gets exactly the same look; or send it inline as style. Unknown fields, unknown presets and unknown fonts are 400 invalid_style naming the field.
# save a style oncecurl -X POST https://api.harmar.ai/v1/style-presets \-H "Authorization: Bearer hk_live_..." \-H "Content-Type: application/json" \-d '{ "name": "Brand", "style": { "preset": "pill", "font": "montserrat", "accentColor": "#D4F25A", "posY": 78 } }'# → 201 { "preset": { "id": "6684e4e5-…", … } }# export a transcript (submitted with keep_media: true) in that stylecurl -X POST https://api.harmar.ai/v1/transcripts/<id>/export \-H "Authorization: Bearer hk_live_..." \-H "Content-Type: application/json" \-d '{ "style_preset_id": "6684e4e5-…" }'# → 202 { "id": "<id>", "export": { "status": "queued", "seconds_charged": 27, "lang": "hy" } }
/v1/transcripts/{id}/exportstyle_preset_iduuid | A saved style (POST /v1/style-presets). Exactly one of style_preset_id / style. |
styleobject | An inline style — any of the fields GET /v1/styles lists, e.g. { "preset": "pill", "font": "montserrat", "accentColor": "#D4F25A" }. Missing fields take the orientation's defaults. |
langISO 639-1 code | Which track to burn: the source language (default) or the translate_to language, when the job has a completed translation. |
platform"instagram" | "youtube" | "tiktok" | Optional — the platform's safe-area defaults for caption placement. |
The export runs asynchronously. Poll GET /v1/transcripts/{id}/export until status is completed — the response then carries a signed download_url valid for one hour — or failed with the reason and the refund. Webhooks receive export.completed / export.failed.
/v1/transcripts/{id}/exportstatus"queued" | "rendering" | "completed" | "failed" | queued while waiting for a render slot; rendering carries progress 0–100. |
download_urlurl | When completed — a signed link to the MP4, valid download_expires_in_seconds (3600). Fetch the endpoint again for a fresh one. |
seconds_charged / seconds_refundedinteger | The charge, and on failure the refund (always the full charge). |
errorstring | On failed: "render_failed" | "source_missing" | "capacity_exceeded_retry_later" | "superseded". |
{"id": "9b2f7c1e-…","status": "completed","lang": "hy","style_preset_id": "6684e4e5-…","seconds_charged": 27,"requested_at": "2026-09-20T13:47:51Z","completed_at": "2026-09-20T13:47:59Z","size_bytes": 31581412,"download_url": "https://…/exported.mp4?X-Amz-…","download_expires_in_seconds": 3600}
Plain text mode
Not building subtitles? Set timestamps to "none" and the response carries only the transcript text — no words or segments arrays. Same quality, same code-switch handling, same price. Good for voice notes, call transcription, meeting notes, and voice-driven apps.
POST /v1/transcripts{ "media_id": "9b2f7c1e-…", "options": { "timestamps": "none" } }
{"id": "9b2f7c1e-…","status": "completed","text": "Բարև ձեզ։ Այսօր կխոսենք ChatGPT-ի մասին։ Это очень простой tool…"}
Webhooks
Pass webhook_url when submitting and we POST on completion or failure, retrying twice (after 30s and 5min). The job stays pollable regardless — a missed webhook never loses a result.
type"transcript.completed" | "transcript.failed" | "export.completed" | "export.failed" | Event type. |
transcript_idstring | Fetch the full result from GET /v1/transcripts/{id}. |
seconds_refundedinteger | On failures — the refunded charge. |
Harmar-Signature: t=1723000000,v1=5f8a…{"type": "transcript.completed","transcript_id": "9b2f7c1e-…","status": "completed","duration_seconds": 61.4,"seconds_charged": 62,"created_at": "2026-08-07T12:00:00.000Z"}
Verify the signature: HMAC-SHA256 of `${t}.${rawBody}` with your key's webhook secret (shown at /app/api):
import { createHmac } from "node:crypto";function verify(signatureHeader, rawBody, webhookSecret) {const { t, v1 } = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));const expected = createHmac("sha256", webhookSecret).update(t + "." + rawBody).digest("hex");return v1 === expected;}
Errors
Errors share one shape. The HTTP status matches the code:
// 402{"error": {"code": "insufficient_credits","message": "Not enough credits for this media.","seconds_needed": 62,"seconds_available": 14}}
invalid_request | 400 | Malformed input — the message says which field. |
unsupported_media | 400 | Not a supported container/format. |
duration_limit | 400 | Media exceeds the 60-minute limit. |
no_audio_track | 400 | The media has no audio track. |
media_missing | 400 | Submit called before the upload PUT finished. |
insufficient_credits | 402 | Balance too low — includes seconds_needed / seconds_available. |
not_found | 404 | Unknown id (or not yours). |
expired | 410 | Transcript passed the 30-day retention window. |
conflict | 409 | Job is in a state that can't accept this call. |
invalid_style | 400 | A style field, preset or font that does not exist — the message names it. |
media_purged | 409 | Export requested on a transcript made without keep_media — submit it again with keep_media: true. |
export_in_progress | 409 | This transcript is already rendering. |
too_many_exports | 429 | One export at a time per account. |
rate_limit | 429 | Too many requests — includes Retry-After. |
- A failed job refunds its full charge automatically.
- Jobs with no detected speech complete with empty output and are charged (the audio was processed).
- quality: "degraded" flags reduced timing confidence (heavy music, very noisy audio) — words are correct, timestamps may drift a couple of seconds in affected sections.
Balance & usage
/v1/balance/v1/usageGET /v1/balance{ "seconds_remaining": 28740, "minutes_remaining": 479 }GET /v1/usage{"entries": [{ "kind": "debit", "delta_seconds": -62, "transcript_id": "9b2f…", "created_at": "…" },{ "kind": "purchase", "delta_seconds": 30000, "created_at": "…" }],"credited_seconds": 30600,"debited_seconds": 62}
Prepaid minute packs (500 / 2,000 / 10,000 min) are purchased at /app/api. Credits never expire.
If you'd rather not watch a balance: turn on auto-recharge at /app/api and we top the account up from your saved card whenever it falls below a threshold you choose. The card is stored by Stripe, not by us — we keep only its token plus the brand and last four digits. You set the threshold and which pack to buy, can switch it off at any time, and can delete the saved card outright. A declined card pauses auto-recharge and emails you; it never retries in a loop. Capped at 3 automatic top-ups per day.
Pricing
Rates are readable from the API so you can compute your own margin instead of hard-coding a number that goes stale. Prices are per product; transcription, timestamps and rendered captions (the styled export) are each listed — the export costs the same per-minute rate as the transcript it burns.
/v1/pricing{"currency": "AMD","effective_from": "2026-08-08","notice_period_days": 30,"products": {"transcription": {"available": true,"unit": "minute_of_media","amd_per_minute": { "from": 25, "to": 40 }},"timestamps": {"available": true,"included_with": "transcription","surcharge_amd_per_minute": 0},"rendered_captions": { "available": false }},"packs": [{ "id": "starter", "minutes": 500, "price_amd": 20000, "amd_per_minute": 40 },{ "id": "growth", "minutes": 2000, "price_amd": 64000, "amd_per_minute": 32 },{ "id": "scale", "minutes": 10000, "price_amd": 250000, "amd_per_minute": 25 }],"trial": { "minutes": 10, "once_per_account": true },"billing": {"metered_on": "media_duration","unit": "second","rounding": "up_to_whole_second","charged_before_processing": true,"refunded_on_failure": true,"max_media_minutes": 60}}
Price changes are announced in writing at least 30 days before they take effect, and effective_from tells you which schedule you are reading.
Data retention
We are not a second database for your users' media. Two windows, both automatic:
- Source media (the file you upload) is deleted from our storage as soon as the transcript is produced. Failed jobs keep theirs for 24 hours so we can diagnose the failure, then it goes too. A job submitted with keep_media: true keeps its media — and any rendered MP4 — for 24 hours after its last activity (creation or the last export), so it can be exported.
- Transcripts stay retrievable for 30 days from creation, then the payload is deleted. After that GET returns 410 expired.
- You can delete a transcript and its media at any time before that with the endpoint below.
/v1/transcripts/{id}{ "id": "9b2f…", "deleted": true, "already_deleted": false }
Idempotent — deleting an already-deleted transcript still returns 200, so a retry after a network blip is not an error. A job that is still processing returns 409; delete it once it finishes. The billing record for a job you were charged for is kept (without any of your content), because it is the receipt for that charge.
Contact
Building an integration? Email ciao@harmar.ai — we work closely with early API partners.