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_..."

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

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"
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.
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; 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.

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