Async batch transcription, document text extraction and voice sessions. Authenticate with a Bearer rtk_ key.

Authentication

Pass your API key as a Bearer token on every request.

Authorization: Bearer rtk_…
  • Keys are scoped to a single organization: every request, upload, job and quota check stays inside that organization — a key can never see another organization's data.
  • A revoked key stops authenticating immediately, with no grace period — the next request made with it gets a 401.
  • If your organization is suspended or permanently deleted, every key on it stops working immediately too, even though the key record itself is untouched.
  • GET /languages and GET /openapi.json are the two exceptions — public reference endpoints that need no key at all.

Quotas and credits

Every service draws from one shared credit pool per organization — there's no separate quota per service.

  • A transcription job is charged one credit per second of audio, rounded up.
  • A document job is charged OCR_CREDITS_PER_PAGE credits per page, deducted once the page count is known — at completion, not at submit time.

The submit-time check is loose

Submitting a job only checks that your organization has credits remaining, not that the job's eventual cost fits inside what's left. A large job can push your balance negative before it finishes — it is never rejected up front for being too expensive to complete.

View your balance and full ledger

Errors

Every error response has the same shape:

{ "error": { "code": "...", "message": "..." } }

Codes returned by the endpoints below:

CodeStatusMeaning
UNAUTHORIZED401Missing, malformed, unknown or revoked API key — or the organization behind it is suspended or deleted.
RATE_LIMITED429You've exceeded the rate limit below.
FORBIDDEN403The audio_token or document_token you supplied belongs to a different organization.
INVALID_REQUEST400The request body failed validation.
QUOTA_EXCEEDED402Your organization has no credits remaining.
JOB_NOT_FOUND404No job with that id exists for your organization.
JOB_NOT_READY409The job hasn't reached a completed state yet.
FORMAT_NOT_AVAILABLE404That result format wasn't requested when the job was submitted.
DOCUMENT_TOO_LARGE413The document exceeds the configured size limit.
DOCUMENT_NOT_FOUND404The uploaded document object could not be found.
INVALID_SOURCE400The document or audio source could not be fetched.
UNSUPPORTED_FORMAT415The document extraction service rejected the file format.
TOO_MANY_PAGES413The document exceeds the extraction service's page limit.
ENCRYPTED_PDF422The PDF is password-protected and can't be processed.
OCR_UNAVAILABLE503The document extraction service is temporarily unavailable.
NOT_FOUND404No voice session with that id exists for your organization.
SESSION_ACTIVE409The voice session is still live — end it (hang up) before deleting it.
VOICE_UNAVAILABLE503The voice service isn't configured for this deployment.

Rate limits

Enforced per API key, not per organization. Design against 60 requests per minute per key, refilling at about one request per second, so a key that hasn't been used recently can burst up to 60 requests at once. A request over the limit gets 429 RATE_LIMITED with a Retry-After header giving the number of seconds to wait.

Treat 429 and Retry-After as authoritative rather than tracking an exact budget yourself. The limiter currently holds its counters in memory per running instance, so the figure above is not a hard guarantee: counters reset when the service is deployed or restarted, and a horizontally scaled deployment enforces the allowance per instance rather than globally.

Webhooks

Set callback_url when you submit a job and rafiq-api POSTs the result to it once the job reaches a terminal state, so you don't have to poll GET .../{id}.

The payload shape depends on the job type and outcome:

// Transcription — completed
{ "id": "…", "status": "completed", "transcript": { ... } }

// Transcription — failed
{ "id": "…", "status": "failed", "error": { "code": "...", "message": "..." } }

// Document — completed
{ "id": "…", "status": "completed", "pages": 12, "formats": ["md"] }

// Document — failed
{ "id": "…", "status": "failed", "error": { "code": "...", "message": "..." } }

A completed document job's payload carries only the page count and formats, never the extracted text — fetch that separately with GET /api/v1/documents/{id}?format=md|txt|json.

A signed delivery carries an X-Rafiq-Signature header: the HMAC-SHA256 hex digest of the exact raw request body, computed with your organization's webhook signing secret.

X-Rafiq-Signature: <hex hmac-sha256>

Verify against the exact bytes of the body as received. Parsing the JSON and re-serializing it before verifying produces a different string and will fail intermittently.

Deliveries with no signing secret configured

If your organization has no signing secret, the delivery still goes out — just without the X-Rafiq-Signature header, never signed with an empty key. A signature computed from an empty secret is something anyone could reproduce, so no header is a more honest signal than a fake one: treat an unsigned delivery as unverified.

Delivery is retried up to 3 times, waiting 1 second then 2 seconds between attempts, if your endpoint doesn't return a successful response. After the last attempt fails, rafiq-api stops trying.

Manage your signing secret in Settings → Webhooks

Verify a delivery (Node.js)

const crypto = require("crypto");

// rawBody must be the exact, unparsed request body — the same bytes rafiq-api
// signed. Verifying JSON.parse(rawBody) re-serialized will fail intermittently:
// key order and whitespace are not guaranteed to round-trip identically.
function isValidSignature(rawBody, signatureHeader, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");

  return (
    signatureHeader.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(signatureHeader), Buffer.from(expected))
  );
}

Connecting to a voice session

POST /voice/sessions returns url and token together, but only once: that token is a short-lived LiveKit access token that expires in expires_in seconds, and is never handed out again by GET /voice/sessions/{id}. Connect with it immediately, using a LiveKit client SDK such as livekit-client.

Connect after creating a session (JavaScript)

import { Room } from "livekit-client";

const res = await fetch("https://api.rafiqspace.ai/api/v1/voice/sessions", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.RAFIQ_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ instructions: "You are a helpful assistant." }),
});
const session = await res.json();

// session.token is short-lived and returned only by this create call —
// GET /voice/sessions/{id} never returns a fresh one.
const room = new Room();
await room.connect(session.url, session.token);
await room.localParticipant.setMicrophoneEnabled(true);

There is no way to fetch a fresh token for an existing session — if the token expires or the connection drops, the session itself is done. Start a new session instead of trying to reconnect to the old room_name.

Endpoints

Getting started

post/api/v1/upload-url

Request a pre-signed upload URL

Request bodyrequired

  • content_typestring

Responses

  • 200Upload URL plus the object token, returned as both `token` (generic) and `audio_token` (legacy alias).
  • 401Unauthorized
  • 429Rate limited
curl -X POST "/api/v1/upload-url" \
  -H "Authorization: Bearer rtk_…" \
  -H "Content-Type: application/json" \
  -d '{
  "content_type": "string"
}'

Speech to text

post/api/v1/transcripts

Create a transcription job

Request bodyrequired

  • audio_tokenstring
  • audio_urlstring <uri>
  • languagestring · one of: id, en · default: id
  • callback_urlstring <uri>

Responses

  • 201Job accepted (status: pending)
  • 400Invalid request
  • 401Unauthorized
  • 402Quota exceeded
  • 429Rate limited
curl -X POST "/api/v1/transcripts" \
  -H "Authorization: Bearer rtk_…" \
  -H "Content-Type: application/json" \
  -d '{
  "audio_token": "string",
  "audio_url": "https://example.com/file",
  "language": "id",
  "callback_url": "https://example.com/file"
}'
get/api/v1/transcripts

List transcription jobs

Parameters

  • limitquery · integer
  • cursorquery · string

Responses

  • 200Paginated job list
curl -X GET "/api/v1/transcripts?limit=1&cursor=string" \
  -H "Authorization: Bearer rtk_…"
get/api/v1/transcripts/{id}

Poll a transcription job

Parameters

  • idpath · string · required

Responses

  • 200Job with status and (when completed) transcript
  • 404Job not found
curl -X GET "/api/v1/transcripts/id_123" \
  -H "Authorization: Bearer rtk_…"
delete/api/v1/transcripts/{id}

Delete a transcription job and its audio

Parameters

  • idpath · string · required

Responses

  • 200Deleted
  • 404Job not found
curl -X DELETE "/api/v1/transcripts/id_123" \
  -H "Authorization: Bearer rtk_…"
get/api/v1/languages

List supported transcription languages

Responses

  • 200Supported languages
curl -X GET "/api/v1/languages"

Documents

post/api/v1/documents

Create a document extraction job

Request bodyrequired

  • document_tokenstring
  • document_urlstring <uri>
  • formatsarray · default: md
  • langstring · default: id,en
  • force_ocrboolean · default: false
  • callback_urlstring <uri>

Responses

  • 201Job accepted (status: processing)
  • 400Invalid request or source
  • 401Unauthorized
  • 402Quota exceeded
  • 403document_token belongs to another organization
  • 404Uploaded document object not found
  • 413Document too large or too many pages
  • 415Unsupported document format
  • 422Encrypted PDF
  • 429Rate limited
  • 503Extraction service unavailable
curl -X POST "/api/v1/documents" \
  -H "Authorization: Bearer rtk_…" \
  -H "Content-Type: application/json" \
  -d '{
  "document_token": "string",
  "document_url": "https://example.com/file",
  "formats": [
    "md"
  ],
  "lang": "id,en",
  "force_ocr": false,
  "callback_url": "https://example.com/file"
}'
get/api/v1/documents

List document extraction jobs

Parameters

  • limitquery · integer
  • cursorquery · string

Responses

  • 200Paginated job list
curl -X GET "/api/v1/documents?limit=1&cursor=string" \
  -H "Authorization: Bearer rtk_…"
get/api/v1/documents/{id}

Poll a document job, or fetch its extracted text

Parameters

  • idpath · string · required
  • formatquery · string

Responses

  • 200Job status, or the extracted text
  • 404Job not found, or format not requested
  • 409Job is not completed yet
curl -X GET "/api/v1/documents/id_123?format=md" \
  -H "Authorization: Bearer rtk_…"
delete/api/v1/documents/{id}

Delete a document job and its stored file

Parameters

  • idpath · string · required

Responses

  • 200Deleted
  • 404Job not found
curl -X DELETE "/api/v1/documents/id_123" \
  -H "Authorization: Bearer rtk_…"

Voice agent

post/api/v1/voice/sessions

Create a voice agent session

Request bodyrequired

  • instructionsstring
  • voicestring
  • enginestring · one of: expressive, responsive
  • languagestring
  • max_minutesinteger
  • callback_urlstring <uri>

Responses

  • 201Session created. Includes the session fields plus `url`, `token`, `room_name` and `expires_in` for connecting a LiveKit client SDK.
  • 400Invalid request
  • 401Unauthorized
  • 402Quota exceeded
  • 429Rate limited
  • 503Voice service is not available for this deployment
curl -X POST "/api/v1/voice/sessions" \
  -H "Authorization: Bearer rtk_…" \
  -H "Content-Type: application/json" \
  -d '{
  "instructions": "string",
  "voice": "string",
  "engine": "expressive",
  "language": "string",
  "max_minutes": 1,
  "callback_url": "https://example.com/file"
}'
get/api/v1/voice/sessions

List voice agent sessions

Parameters

  • limitquery · integer
  • cursorquery · string

Responses

  • 200Paginated session list
curl -X GET "/api/v1/voice/sessions?limit=1&cursor=string" \
  -H "Authorization: Bearer rtk_…"
get/api/v1/voice/sessions/{id}

Fetch a voice agent session

Parameters

  • idpath · string · required

Responses

  • 200Session with status, transcript and recordings.{user,agent} presigned URLs
  • 404Session not found
curl -X GET "/api/v1/voice/sessions/id_123" \
  -H "Authorization: Bearer rtk_…"
delete/api/v1/voice/sessions/{id}

Delete a finished voice session and its recordings

Parameters

  • idpath · string · required

Responses

  • 204Deleted — the stored recordings are removed along with the session
  • 404Session not found
  • 409Session is still live — end it before deleting
curl -X DELETE "/api/v1/voice/sessions/id_123" \
  -H "Authorization: Bearer rtk_…"
get/api/v1/voices

List the available voices

Responses

  • 200Available voices
curl -X GET "/api/v1/voices"