Dokumentimi

Nisja e shpejtë

API i Fjalim kthen shqipen e folur në tekst, me HTTPS dhe JSON. Adresa bazë: https://api.fjalim.com. Transkriptimi kushton 0,012 € në minutë, i faturuar për sekondë të nisur.

Kjo faqe është një hyrje e shkurtër në shqip. Udhëzuesi i plotë (webhook-e, formati i transkriptit, titrat, gabimet, kufijtë) është në anglisht, dhe referenca e API-t gjenerohet nga skedari OpenAPI.

Nga skedari te teksti, në pesë hapa

  1. Merrni një çelës. Hyni në aplikacion dhe krijoni një çelës API. Fillon me fj_live_ dhe shfaqet vetëm një herë. Llogaritë e reja marrin 15 minuta falas pas verifikimit të emailit.
  2. Ngarkoni. POST /v1/uploads kthen një URL të nënshkruar. Bëni PUT të skedarit aty, pastaj thirrni POST /v1/uploads/{id}/complete.
  3. Krijoni. POST /v1/transcriptions me upload_id. Përgjigjja është 202 me një objekt transkriptimi në gjendjen queued.
  4. Prisni. Pyesni GET /v1/transcriptions/{id} çdo disa sekonda, ose vendosni një webhook_url dhe ne ju thërrasim vetë.
  5. Eksportoni. 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)

Një sekondë audio kushton 200 µ€ (mikro-euro). Faturimi bëhet kur punëtori ka matur kohëzgjatjen e audios. Nëse krediti është nën 200 µ€, përgjigjja është 402 insufficient_credits.

Autentikimi

Dërgoni çelësin në titullin Authorization në çdo kërkesë:

Authorization: Bearer fj_live_...
  • Çelësat krijohen dhe hiqen në aplikacion. Ruajmë vetëm një hash, ndaj një çelës i humbur nuk mund të shfaqet sërish. Krijoni një të ri.
  • Mbajini çelësat në serverin tuaj. Kurrë në shfletues ose në aplikacion celulari.
  • Një çelës që mungon ose është i gabuar kthen 401 unauthorized.

Transkripto një URL

Në vend që ta ngarkoni, mund të dërgoni një audio_url publik. Dërgoni vetëm njërin nga upload_id dhe audio_url. URL-ja duhet të jetë https dhe nuk duhet të çojë te adresa private, loopback ose link-local.

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", ...}

Webhook-e

Vendosni webhook_url kur krijoni një transkriptim. Kur përfundon ose dështon, ne dërgojmë një POST me JSON-in e transkriptimit. Çdo kërkesë ka titullin Fjalim-Signature: t=<sekonda unix>,v1=<hex>.

  1. Lexoni trupin të papërpunuar, para se ta kaloni nëpër parser JSON.
  2. Ndajeni titullin në t dhe v1.
  3. Refuzojeni kërkesën nëse t ndryshon më shumë se 5 minuta (300 sekonda) nga ora juaj.
  4. Llogaritni HMAC-SHA256 me sekretin e webhook-ut mbi tekstin "<t>.<trupi i papërpunuar>" dhe krahasojeni me v1 me krahasim në kohë konstante.

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}

Sekreti fillon me whsec_, dhe e përdorni të plotë si çelës. Mund ta shihni ose ndërroni në aplikacion. Nëse përgjigja nuk është 2xx brenda 10 sekondash, riprovojmë pas 1 minute, 5 minutash, 30 minutash, 2 orësh, 6 orësh dhe 24 orësh. E njëjta ngjarje mund të vijë më shumë se një herë: filtrojeni sipas id.

Gabimet

Çdo gabim ka të njëjtën formë JSON dhe një status HTTP përkatës:

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." } }
KodiStatusiKuptimi
invalid_request400Trupi ose një parametër nuk është i vlefshëm.
unauthorized401Çelësi mungon, është i gabuar ose është hequr.
insufficient_credits402Krediti është nën 200 µ€. Shtoni kredi.
not_found404Burimi nuk ekziston ose nuk është i juaji.
not_ready409Transkripti nuk është gati ende.
version_conflict409Versioni në If-Match është i vjetër.
rate_limited429Më shumë se 60 kërkesa në minutë. Prisni sekondat e Retry-After.
internal_error500Gabim nga ana jonë. Riprovoni me të njëjtin Idempotency-Key.

Lista e plotë e kodeve dhe e gabimeve të një pune që dështon (invalid_audio, audio_too_long, source_unreachable e të tjera) është në udhëzuesin anglisht.

Kufijtë

KufiriVlera
Madhësia e skedarit2 GB për skedar
Kohëzgjatja e audios4 orë për punë
Kërkesat API60 në minutë për llogari
Prova falas në faqe60 sekondat e para të një skedari, deri në 100 MB, 3 në 24 orë për adresë IP

Audioja ruhet 90 ditë në ruajtje të shpejtë dhe fshihet pas 365 ditësh. Mund ta fshini kurdo me DELETE /v1/transcriptions/{id}. Më shumë te ruajtja e të dhënave.