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
- 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. - Upload.
POST /v1/uploadsreturns a signed URL.PUTthe file to it, then callPOST /v1/uploads/{id}/complete. - Create.
POST /v1/transcriptionswith theupload_id. It answers202with a transcription object in thequeuedstate. - Wait. Poll
GET /v1/transcriptions/{id}every few seconds, or set awebhook_urland let us call you. - 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.
POST /v1/uploadswith{"filename", "content_type", "size_bytes"}. The answer is201with{"id", "upload_url", "method": "PUT", "headers", "expires_at"}. The URL is valid for one hour.PUTthe file bytes toupload_url, sending the returnedheadersexactly as they are. The body must be the raw file, and its size must equalsize_bytes.POST /v1/uploads/{id}/complete. We check that the object exists and that its size matches. A mismatch returns400 size_mismatch, and a missing object returns409 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, orsource_too_largewhen 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
| Field | Meaning |
|---|---|
id, status | queued, processing, completed, failed or canceled. |
created_at, completed_at | ISO timestamps. completed_at is null until it finishes. |
duration_seconds, billed_seconds, cost_micro_eur | Set once the audio is measured. |
error | {"code", "message"} when failed, otherwise null. |
metadata | The JSON you sent. |
version | The latest transcript version. 1 is the model output, and each edit adds one. |
filename, content_type | From 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.
- Read the raw body. Do not parse and re-serialize it first.
- Split the header into
tandv1. - Reject the request if
tis more than 5 minutes (300 seconds) away from your clock. - Compute the HMAC and compare it to
v1with 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
2xxanswer 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_urlapply:httpsonly, 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.
textis the segments joined by a single space.speakerisnullfor now, because there is no speaker detection. Editors can set a name such as"Folësi 1".confidenceisnullwhen the model gives none.- The response carries
ETagandX-Transcript-Version. Add?version=to read an older version. - Before the job completes the route returns
409 not_ready. After a failure it returns409 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.
| Rule | Default | Parameter |
|---|---|---|
| Characters per line | 42 | max_chars_per_line |
| Lines per cue | 2 | max_lines |
| Characters per second | 17 | max_cps |
| Minimum cue duration | 0.833 s | min_duration |
| Maximum cue duration | 7 s | max_duration |
| Minimum gap between cues | 0.083 s | min_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×tamps=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." } } | Code | Status | Meaning |
|---|---|---|
invalid_request | 400 | The body or a parameter is not valid. The message names the field. |
size_mismatch | 400 | The uploaded file size differs from size_bytes. |
ssrf_blocked | 400 | The audio_url or webhook_url points to a blocked address. |
unauthorized | 401 | The key is missing, wrong or revoked. |
insufficient_credits | 402 | The balance is below 200 µ€. Add credit. |
forbidden, origin_not_allowed, session_required | 403 | The request is not allowed. The last two apply to the web app session. |
not_found | 404 | The resource does not exist or is not yours. |
upload_incomplete | 409 | The file was not uploaded yet. |
not_ready, transcript_unavailable | 409 | The transcript is not ready yet, or the job failed and has none. |
version_conflict | 409 | Your If-Match version is stale. Fetch the latest and merge. |
payload_too_large | 413 | The request is too large. |
precondition_required | 428 | If-Match is required when saving a transcript. |
rate_limited | 429 | More than 60 requests in a minute. Wait for Retry-After seconds. |
internal_error | 500 | Something went wrong on our side. Retry with the same Idempotency-Key. |
billing_unavailable | 502, 503 | The 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.code | Meaning |
|---|---|
invalid_audio | The file has no audio stream or cannot be decoded. |
audio_too_long | The audio is longer than 4 hours. |
insufficient_credits | The balance was too low when the audio was measured. |
source_unreachable | An audio_url could not be fetched. |
source_too_large | An audio_url is larger than 2 GB. |
internal_error | We tried three times and it still failed. A billed job is refunded. |
Limits
| Limit | Value |
|---|---|
| Upload size | 2 GB per file |
| Audio duration | 4 hours per job |
| API rate | 60 requests per minute per account |
| Metadata | 4 KB of JSON |
| Upload URL lifetime | 1 hour |
| Billing | €0.012 per minute, per started second; minimum balance 200 µ€ to start |
| Free trial on the website | First 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 answers204. 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.