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.

Base URL:https://api.harmar.ai

Quickstart

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.

curl
# 1 — create an upload
curl -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_url
curl -X PUT "<upload_url>" \
-H "Content-Type: video/mp4" \
--data-binary @reel.mp4
 
# 3 — submit the job
curl -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 result
curl 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.

shell
export HARMAR_API_KEY=hk_live_...
 
# identify the language, write an SRT
npx -y harmar-ai transcribe reel.mp4 --lang auto --srt --out reel.srt
 
# Russian speech + an Armenian subtitle track, as VTT
npx -y harmar-ai transcribe talk.mp4 --lang ru --translate-to hy --vtt
 
npx -y harmar-ai languages # what --lang accepts, live
npx -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.

connector URL
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:

shell
claude mcp add harmar -e HARMAR_API_KEY=hk_live_... -- npx -y harmar-ai mcp
mcp.json
{
"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.

Header
Authorization: Bearer hk_live_...

1 · Create an upload

POST/v1/uploads

Returns 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.

filename
string
required
File name with extension — determines the media type (mp4, mov, webm, m4a, mp3, wav).
file_size
integer
required
Exact size in bytes. Max 5 GB.
Request
POST /v1/uploads
{ "filename": "reel.mp4", "file_size": 12345678 }
Response
{
"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:

PUT
curl -X PUT "<upload_url>" -H "Content-Type: video/mp4" --data-binary @reel.mp4

2 · Submit a transcript

POST/v1/transcripts

Starts 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_id
string
required
The id returned by POST /v1/uploads (after the PUT finished).
webhook_url
string
HTTPS URL we POST to on completion or failure. Signed — see Webhooks.
script_text
string
Align-only mode: you supply the ground-truth text, we compute only the timing. Max 100k chars.
source_lang
ISO 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_media
boolean
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.punctuation
boolean
default: true
false strips punctuation from the output (numbers like 3.5 and words like ChatGPT-ը stay intact).
options.speakers
boolean
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.
Request
POST /v1/transcripts
{
"media_id": "9b2f7c1e-…",
"webhook_url": "https://yourapp.com/hooks/harmar",
"options": {
"timestamps": "word",
"punctuation": true,
"speakers": true,
"lyrics": "exclude"
}
}
Response
// 202 Accepted
{
"id": "9b2f7c1e-…",
"status": "processing",
"duration_seconds": 61.4,
"seconds_charged": 62
}

3 · Fetch the result

GET/v1/transcripts/{id}

Poll until status becomes completed or failed (or use a webhook). Completed responses carry the transcript in every enabled shape.

Response
{
"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.
progress
integer 0–100
Present while processing — real pipeline progress, good for a progress bar.
media_retained
boolean
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_lang
ISO 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.
text
string
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_url
string
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_charged
integer
What this job cost your balance (= media duration rounded up).
error
string
Present on failed jobs. The charge is refunded automatically.

Subtitle files

GET/v1/transcripts/{id}/srt
GET/v1/transcripts/{id}/vtt

Ready-made subtitle files, one cue per sentence segment. Same auth, plain-text responses:

?lang
ISO 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.
SRT
1
00:00:00,420 --> 00:00:00,970
Բարև ձեզ։
 
2
00:00:01,020 --> 00:00:02,180
ChatGPT-ը լավ 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.

GET/v1/styles
GET/v1/style-presets
POST/v1/style-presets
DELETE/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.

curl
# save a style once
curl -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 style
curl -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" } }
POST/v1/transcripts/{id}/export
style_preset_id
uuid
A saved style (POST /v1/style-presets). Exactly one of style_preset_id / style.
style
object
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.
lang
ISO 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.

GET/v1/transcripts/{id}/export
status
"queued" | "rendering" | "completed" | "failed"
queued while waiting for a render slot; rendering carries progress 0–100.
download_url
url
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_refunded
integer
The charge, and on failure the refund (always the full charge).
error
string
On failed: "render_failed" | "source_missing" | "capacity_exceeded_retry_later" | "superseded".
JSON
{
"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.

Request
POST /v1/transcripts
{ "media_id": "9b2f7c1e-…", "options": { "timestamps": "none" } }
Response
{
"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_id
string
Fetch the full result from GET /v1/transcripts/{id}.
seconds_refunded
integer
On failures — the refunded charge.
Webhook POST
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):

Node.js
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:

Response
// 402
{
"error": {
"code": "insufficient_credits",
"message": "Not enough credits for this media.",
"seconds_needed": 62,
"seconds_available": 14
}
}
invalid_request400Malformed input — the message says which field.
unsupported_media400Not a supported container/format.
duration_limit400Media exceeds the 60-minute limit.
no_audio_track400The media has no audio track.
media_missing400Submit called before the upload PUT finished.
insufficient_credits402Balance too low — includes seconds_needed / seconds_available.
not_found404Unknown id (or not yours).
expired410Transcript passed the 30-day retention window.
conflict409Job is in a state that can't accept this call.
invalid_style400A style field, preset or font that does not exist — the message names it.
media_purged409Export requested on a transcript made without keep_media — submit it again with keep_media: true.
export_in_progress409This transcript is already rendering.
too_many_exports429One export at a time per account.
rate_limit429Too 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

GET/v1/balance
GET/v1/usage
Response
GET /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.

GET/v1/pricing
Response
{
"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.
DELETE/v1/transcripts/{id}
Response
{ "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.