Docs

Fjalim API

Albanian speech-to-text over HTTPS and JSON. Upload a file, start a transcription, read the transcript. Base URL: https://api.fjalim.com. Batch transcription costs €0.012 per minute, billed per started second.

This guide is in English. Shqip: nisja e shpejtë. For every route and field, see the API reference, which is generated from the OpenAPI file.

Quickstart

  1. Get a key. Sign in to the web app and create an API key. It starts with fj_live_ and is shown once. New accounts get 15 free minutes after verifying their email.
  2. Upload. POST /v1/uploads returns a signed URL. PUT the file to it, then call POST /v1/uploads/{id}/complete.
  3. Create. POST /v1/transcriptions with the upload_id. It answers 202 with a transcription object in the queued state.
  4. Wait. Poll GET /v1/transcriptions/{id} every few seconds, or set a webhook_url and let us call you.
  5. Export. GET /v1/transcriptions/{id}/transcript?format=json|txt|srt|vtt.

curl

export FJALIM_KEY="fj_live_..."          # create a key in the web app
API=https://api.fjalim.com
FILE=seanca.mp3
SIZE=$(wc -c < "$FILE" | tr -d ' ')

# 1. Ask for an upload URL
UPLOAD=$(curl -sS "$API/v1/uploads" \
  -H "Authorization: Bearer $FJALIM_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"filename\":\"$FILE\",\"content_type\":\"audio/mpeg\",\"size_bytes\":$SIZE}")
UPLOAD_ID=$(echo "$UPLOAD" | jq -r .id)
UPLOAD_URL=$(echo "$UPLOAD" | jq -r .upload_url)

# 2. PUT the bytes, with the headers from the response
HEADERS=()
while IFS= read -r h; do HEADERS+=(-H "$h"); done \
  < <(echo "$UPLOAD" | jq -r '.headers | to_entries[] | "\(.key): \(.value)"')
curl -sS -X PUT "$UPLOAD_URL" "${HEADERS[@]}" --data-binary @"$FILE"

# 3. Confirm the upload
curl -sS -X POST "$API/v1/uploads/$UPLOAD_ID/complete" \
  -H "Authorization: Bearer $FJALIM_KEY"

# 4. Create the transcription
JOB=$(curl -sS "$API/v1/transcriptions" \
  -H "Authorization: Bearer $FJALIM_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d "{\"upload_id\":\"$UPLOAD_ID\"}" | jq -r .id)

# 5. Poll until it finishes (or use a webhook)
while :; do
  STATUS=$(curl -sS "$API/v1/transcriptions/$JOB" \
    -H "Authorization: Bearer $FJALIM_KEY" | jq -r .status)
  echo "$STATUS"
  [ "$STATUS" = completed ] && break
  [ "$STATUS" = failed ] || [ "$STATUS" = canceled ] && exit 1
  sleep 3
done

# 6. Export
curl -sS "$API/v1/transcriptions/$JOB/transcript?format=srt" \
  -H "Authorization: Bearer $FJALIM_KEY" -o seanca.srt

JavaScript

// Node 20+ (global fetch). Run as an ES module.
import { readFile, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';

const API = 'https://api.fjalim.com';
const KEY = process.env.FJALIM_KEY; // fj_live_...

async function call(path, init = {}) {
  const res = await fetch(API + path, {
    ...init,
    headers: { Authorization: `Bearer ${KEY}`, ...init.headers },
  });
  if (!res.ok) {
    const { error } = await res.json();
    throw new Error(`${error.code}: ${error.message}`);
  }
  return res;
}
const json = { 'Content-Type': 'application/json' };

const file = await readFile('seanca.mp3');

// 1. Ask for an upload URL
const upload = await (await call('/v1/uploads', {
  method: 'POST',
  headers: json,
  body: JSON.stringify({ filename: 'seanca.mp3', content_type: 'audio/mpeg', size_bytes: file.length }),
})).json();

// 2. PUT the bytes, with the headers from the response
const put = await fetch(upload.upload_url, { method: upload.method, headers: upload.headers, body: file });
if (!put.ok) throw new Error(`upload failed: ${put.status}`);

// 3. Confirm the upload
await call(`/v1/uploads/${upload.id}/complete`, { method: 'POST' });

// 4. Create the transcription
const job = await (await call('/v1/transcriptions', {
  method: 'POST',
  headers: { ...json, 'Idempotency-Key': randomUUID() },
  body: JSON.stringify({ upload_id: upload.id }),
})).json();

// 5. Poll until it finishes (or use a webhook)
let t;
do {
  await new Promise((r) => setTimeout(r, 3000));
  t = await (await call(`/v1/transcriptions/${job.id}`)).json();
} while (!['completed', 'failed', 'canceled'].includes(t.status));
if (t.status !== 'completed') throw new Error(t.error?.message ?? t.status);

// 6. Export
const srt = await (await call(`/v1/transcriptions/${job.id}/transcript?format=srt`)).text();
await writeFile('seanca.srt', srt);

Python

# pip install httpx
import os
import time
import uuid

import httpx

API = "https://api.fjalim.com"
client = httpx.Client(
    base_url=API,
    headers={"Authorization": f"Bearer {os.environ['FJALIM_KEY']}"},  # fj_live_...
    timeout=30,
)


def check(res: httpx.Response) -> httpx.Response:
    if res.is_error:
        err = res.json()["error"]
        raise RuntimeError(f"{err['code']}: {err['message']}")
    return res


with open("seanca.mp3", "rb") as f:
    data = f.read()

# 1. Ask for an upload URL
upload = check(client.post("/v1/uploads", json={
    "filename": "seanca.mp3",
    "content_type": "audio/mpeg",
    "size_bytes": len(data),
})).json()

# 2. PUT the bytes, with the headers from the response
check(httpx.put(upload["upload_url"], headers=upload["headers"], content=data, timeout=600))

# 3. Confirm the upload
check(client.post(f"/v1/uploads/{upload['id']}/complete"))

# 4. Create the transcription
job = check(client.post(
    "/v1/transcriptions",
    json={"upload_id": upload["id"]},
    headers={"Idempotency-Key": str(uuid.uuid4())},
)).json()

# 5. Poll until it finishes (or use a webhook)
while True:
    time.sleep(3)
    t = check(client.get(f"/v1/transcriptions/{job['id']}")).json()
    if t["status"] in ("completed", "failed", "canceled"):
        break
if t["status"] != "completed":
    raise RuntimeError((t.get("error") or {}).get("message", t["status"]))

# 6. Export
srt = check(client.get(f"/v1/transcriptions/{job['id']}/transcript", params={"format": "srt"})).text
open("seanca.srt", "w", encoding="utf-8").write(srt)

Authentication

Send your key in the Authorization header on every request:

Authorization: Bearer fj_live_<32 random bytes, base64url>
  • Create and revoke keys in the web app. We store only a hash, so a lost key cannot be shown again. Create a new one.
  • Keep keys on your server. Never put them in a browser or a mobile app.
  • A missing or wrong key returns 401 unauthorized.
  • The web app uses a session cookie instead. Cookie requests that change data must come from an allowed origin. API keys are not affected by this rule.

Uploads

Files go straight to storage through a signed URL, so large files do not pass through the API.

  1. POST /v1/uploads with {"filename", "content_type", "size_bytes"}. The answer is 201 with {"id", "upload_url", "method": "PUT", "headers", "expires_at"}. The URL is valid for one hour.
  2. PUT the file bytes to upload_url, sending the returned headers exactly as they are. The body must be the raw file, and its size must equal size_bytes.
  3. POST /v1/uploads/{id}/complete. We check that the object exists and that its size matches. A mismatch returns 400 size_mismatch, and a missing object returns 409 upload_incomplete.

Files can be up to 2 GB and 4 hours of audio. Audio and video files are accepted (MP3, WAV, M4A, MP4, OGG, FLAC and others that ffmpeg reads).

Transcribe a URL

Instead of uploading, send a public audio_url. Send exactly one of upload_id and audio_url.

  • The URL must use https.
  • It must not resolve to a private, loopback or link-local address. A blocked address returns 400 ssrf_blocked.
  • We follow at most 3 redirects and stop at 2 GB.
  • If the file cannot be fetched, the transcription fails with source_unreachable, or source_too_large when it is over the limit.

curl

curl https://api.fjalim.com/v1/transcriptions \
  -H "Authorization: Bearer $FJALIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{"audio_url": "https://example.com/podcast/episodi-42.mp3"}'

JavaScript

const res = await fetch('https://api.fjalim.com/v1/transcriptions', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.FJALIM_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ audio_url: 'https://example.com/podcast/episodi-42.mp3' }),
});
const job = await res.json(); // 202 { id, status: "queued", ... }

Python

job = httpx.post(
    "https://api.fjalim.com/v1/transcriptions",
    headers={"Authorization": f"Bearer {os.environ['FJALIM_KEY']}"},
    json={"audio_url": "https://example.com/podcast/episodi-42.mp3"},
).json()  # 202 {"id": ..., "status": "queued", ...}

Transcriptions

POST /v1/transcriptions accepts upload_id or audio_url, plus an optional webhook_url and metadata (your own JSON, up to 4 KB). Send an Idempotency-Key header to make retries safe: the same key returns the same transcription.

curl https://api.fjalim.com/v1/transcriptions \
  -H "Authorization: Bearer $FJALIM_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "upload_id": "0b6f1c2e-7b0a-4a52-9c1f-3c5d1a8e2f10",
    "webhook_url": "https://example.com/hooks/fjalim",
    "metadata": { "episode": 42 }
  }'

The balance must be at least 200 µ€ (one second), otherwise the answer is 402 insufficient_credits. Billing happens when the worker has measured the audio: billed_seconds = ceil(duration) and the cost is billed_seconds × 200 µ€. All money amounts in the API are integer micro-euros (1 € = 1,000,000 µ€). If a billed job fails, the cost is refunded automatically.

The transcription object

FieldMeaning
id, statusqueued, processing, completed, failed or canceled.
created_at, completed_atISO timestamps. completed_at is null until it finishes.
duration_seconds, billed_seconds, cost_micro_eurSet once the audio is measured.
error{"code", "message"} when failed, otherwise null.
metadataThe JSON you sent.
versionThe latest transcript version. 1 is the model output, and each edit adds one.
filename, content_typeFrom the upload, or null for audio_url jobs.

Other routes: GET /v1/transcriptions?limit=20&cursor= (newest first), GET /v1/transcriptions/{id}/audio (a 302 to a signed link for playback), DELETE /v1/transcriptions/{id}, GET /v1/balance and GET /v1/usage?from&to.

Webhooks

Set webhook_url when you create a transcription. When it completes or fails, we send a POST with JSON:

POST /hooks/fjalim HTTP/1.1
Content-Type: application/json
Fjalim-Signature: t=1762160000,v1=5b1c0f...e41a

{
  "id": "d5a1c1c0-...",
  "event": "transcription.completed",
  "created_at": "2026-11-03T09:14:20.000Z",
  "data": { "id": "5f4a3c2b-...", "status": "completed", "billed_seconds": 748, "version": 1, "...": "..." }
}

Events are transcription.completed and transcription.failed. data is the transcription object.

Verify the signature

Every request carries Fjalim-Signature: t=<unix seconds>,v1=<hex>. The value v1 is the hex HMAC-SHA256, keyed with your webhook secret, of the text "<t>.<raw body>": the timestamp, a dot, then the exact bytes we sent.

  1. Read the raw body. Do not parse and re-serialize it first.
  2. Split the header into t and v1.
  3. Reject the request if t is more than 5 minutes (300 seconds) away from your clock.
  4. Compute the HMAC and compare it to v1 with a constant-time comparison.

Node.js

import { createHmac, timingSafeEqual } from 'node:crypto';

/**
 * rawBody: the exact bytes received (Buffer or string), before any JSON parsing.
 * secret:  your webhook secret, the full string including the "whsec_" prefix.
 */
export function verifyFjalimSignature(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(
    String(header ?? '').split(',').map((p) => p.trim().split('=', 2)),
  );
  const t = parts.t;
  const v1 = parts.v1;
  if (!/^\d+$/.test(t ?? '') || !/^[0-9a-f]+$/i.test(v1 ?? '')) return false;

  // Reject old (or future) timestamps: replay protection.
  if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;

  const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody, 'utf8');
  const expected = createHmac('sha256', secret)
    .update(Buffer.concat([Buffer.from(`${t}.`), body]))
    .digest();
  const given = Buffer.from(v1, 'hex');
  return given.length === expected.length && timingSafeEqual(given, expected);
}

// Express: read the body as raw bytes so the signature matches.
import express from 'express';
const app = express();
app.post('/hooks/fjalim', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verifyFjalimSignature(req.body, req.get('Fjalim-Signature'), process.env.FJALIM_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }
  const event = JSON.parse(req.body.toString('utf8'));
  // Handle event.event === 'transcription.completed' | 'transcription.failed'.
  // Deliveries can repeat: de-duplicate on event.id.
  res.sendStatus(200);
});

Python

import hashlib
import hmac
import os
import time


def verify_fjalim_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    """raw_body: the exact bytes received. secret: the full whsec_... string."""
    try:
        parts = dict(p.strip().split("=", 1) for p in (header or "").split(","))
        t, v1 = parts["t"], parts["v1"]
        timestamp = int(t)
    except (KeyError, ValueError):
        return False

    # Reject old (or future) timestamps: replay protection.
    if abs(time.time() - timestamp) > tolerance:
        return False

    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)


# FastAPI
from fastapi import FastAPI, HTTPException, Request

app = FastAPI()


@app.post("/hooks/fjalim")
async def fjalim_hook(request: Request):
    raw = await request.body()  # bytes, before JSON parsing
    secret = os.environ["FJALIM_WEBHOOK_SECRET"]  # the full whsec_... string
    if not verify_fjalim_signature(raw, request.headers.get("Fjalim-Signature", ""), secret):
        raise HTTPException(status_code=400)
    event = await request.json()
    # De-duplicate on event["id"]; handle transcription.completed / .failed
    return {"ok": True}

Your webhook secret starts with whsec_. Use the whole string as the key. Read or rotate it in the web app (GET /account/webhook-secret, POST /account/webhook-secret/rotate).

Delivery and retries

  • Any 2xx answer marks the delivery as done. Answer within 10 seconds.
  • Otherwise we retry after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours, then stop.
  • The same event can arrive more than once. De-duplicate on id.
  • The same address rules as audio_url apply: https only, and no private addresses.

Transcript format

GET /v1/transcriptions/{id}/transcript returns version 1 of the format by default:

{
  "version": 1,
  "language": "sq",
  "model": "fjalim-sq-1",
  "duration": 12.48,
  "text": "Mirëdita. Sot flasim për zgjedhjet.",
  "segments": [
    {
      "id": 0, "start": 0.32, "end": 2.9, "speaker": null,
      "text": "Mirëdita. Sot flasim për zgjedhjet.",
      "words": [
        { "text": "Mirëdita.", "start": 0.32, "end": 0.96, "confidence": null }
      ]
    }
  ],
  "captions": null
}
  • Times are seconds, rounded to milliseconds. Text is Unicode NFC.
  • text is the segments joined by a single space.
  • speaker is null for now, because there is no speaker detection. Editors can set a name such as "Folësi 1".
  • confidence is null when the model gives none.
  • The response carries ETag and X-Transcript-Version. Add ?version= to read an older version.
  • Before the job completes the route returns 409 not_ready. After a failure it returns 409 transcript_unavailable.

Captions and exports

Ask for format=srt or format=vtt to get captions. If nobody has edited the captions, the cues are built from the word timings, sentence by sentence, using these rules. Override any of them with a query parameter.

RuleDefaultParameter
Characters per line42max_chars_per_line
Lines per cue2max_lines
Characters per second17max_cps
Minimum cue duration0.833 smin_duration
Maximum cue duration7 smax_duration
Minimum gap between cues0.083 smin_gap

Once someone edits captions in the editor, the transcript carries them in captions, and exports use those cues as they are:

"captions": [
  { "id": 0, "start": 0.32, "end": 2.9, "lines": ["Mirëdita.", "Sot flasim për zgjedhjet."] }
]

For format=txt you can add timestamps=true and speakers=true.

# Formats: json (default), txt, srt, vtt
curl "https://api.fjalim.com/v1/transcriptions/$JOB/transcript?format=vtt&max_chars_per_line=32&max_lines=2" \
  -H "Authorization: Bearer $FJALIM_KEY"

# Plain text with timestamps and speakers
curl "https://api.fjalim.com/v1/transcriptions/$JOB/transcript?format=txt&timestamps=true&speakers=true" \
  -H "Authorization: Bearer $FJALIM_KEY"

# Save an edited transcript (If-Match is the version you read)
curl -X PUT "https://api.fjalim.com/v1/transcriptions/$JOB/transcript" \
  -H "Authorization: Bearer $FJALIM_KEY" -H "Content-Type: application/json" \
  -H "If-Match: 1" --data @edited.json

PUT /v1/transcriptions/{id}/transcript saves an edited transcript. Send If-Match: <version>. A stale version returns 409 version_conflict, and a missing header returns 428 precondition_required. A save creates version n+1.

Errors

Every error has the same JSON shape and an HTTP status that matches:

HTTP/1.1 402 Payment Required
Content-Type: application/json

{ "error": { "code": "insufficient_credits", "message": "Your balance is too low. Add credit to start a transcription." } }
CodeStatusMeaning
invalid_request400The body or a parameter is not valid. The message names the field.
size_mismatch400The uploaded file size differs from size_bytes.
ssrf_blocked400The audio_url or webhook_url points to a blocked address.
unauthorized401The key is missing, wrong or revoked.
insufficient_credits402The balance is below 200 µ€. Add credit.
forbidden, origin_not_allowed, session_required403The request is not allowed. The last two apply to the web app session.
not_found404The resource does not exist or is not yours.
upload_incomplete409The file was not uploaded yet.
not_ready, transcript_unavailable409The transcript is not ready yet, or the job failed and has none.
version_conflict409Your If-Match version is stale. Fetch the latest and merge.
payload_too_large413The request is too large.
precondition_required428If-Match is required when saving a transcript.
rate_limited429More than 60 requests in a minute. Wait for Retry-After seconds.
internal_error500Something went wrong on our side. Retry with the same Idempotency-Key.
billing_unavailable502, 503The payment provider is unreachable. Try again shortly.

Account, trial and sign-in routes add invalid_credentials, invalid_token, email_in_use, too_many_keys, trial_limit, trial_capacity, trial_used and turnstile_failed. They are described in the API reference.

When a transcription fails

A job that fails has status: "failed" and an error object.

error.codeMeaning
invalid_audioThe file has no audio stream or cannot be decoded.
audio_too_longThe audio is longer than 4 hours.
insufficient_creditsThe balance was too low when the audio was measured.
source_unreachableAn audio_url could not be fetched.
source_too_largeAn audio_url is larger than 2 GB.
internal_errorWe tried three times and it still failed. A billed job is refunded.

Limits

LimitValue
Upload size2 GB per file
Audio duration4 hours per job
API rate60 requests per minute per account
Metadata4 KB of JSON
Upload URL lifetime1 hour
Billing€0.012 per minute, per started second; minimum balance 200 µ€ to start
Free trial on the websiteFirst 60 seconds of one file, up to 100 MB, 3 per IP per 24 hours

Retention and deletion

  • Uploaded audio stays in fast storage for 90 days, then in cold storage, and is deleted after 365 days.
  • Transcripts and their versions are deleted 365 days after creation.
  • Billing records are kept for accounting.
  • DELETE /v1/transcriptions/{id} removes the audio and every transcript version immediately and answers 204. The job record keeps only billing fields.

Details: data retention and the privacy policy. We do not train models on customer audio without your permission.