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_..."
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_lang"hy" | "ru" | "en" default: "hy" | Optional — which language the media is spoken in. Defaults to Armenian. Set "ru" or "en" for Russian/English source media. Independent of translate_to — you can request a non-Armenian source with no translation at all, for plain transcription+timing in that language. |
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. |
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:
?lang"hy" | "ru" | "en" | 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 ա։
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" | 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. |
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; today transcription and timestamps are one price, and rendered captions are listed as unavailable rather than omitted so you can build a complete product table now.
/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.
- 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.