Skip to main content

Conversion API

Rolling out — not yet callable

The contract below is final and implementation is underway, but the endpoints are not live yet. Requests to /api/v1/convert will 404 until the rollout completes, and it ships behind a kill switch that starts disabled.

Build against this page, but do not ship a dependency on it without confirming availability. Everything documented here is the agreed contract; the surrounding behaviour notes are drawn from the implementation design.

The Conversion API is a machine-facing façade over the platform's existing capability. You give it a file URL, a template and a webhook, and minutes later it POSTs you the public URL of a finished document.

POST /api/v1/convert {file_url, template_id, webhook_url}
→ 202 {job_id, status: "queued"}
→ … platform downloads, transcribes/extracts, generates …
→ POST your webhook {output_url, …}

This is what the Generation API cannot do on its own: generation is JWT-only and requires you to already own a notebook with sources ingested. Conversion needs neither — it accepts an API key and manufactures the notebook internally.

Typical uses: a meeting recording into a presentation, an interview transcript into a formatted report, a PDF into a summary deck.


How it differs from generation

GenerationConversion
CredentialJWT onlyAPI key (JWT also accepted)
InputSources already ingested into your notebookOne file URL in the request
NotebookYou create and manage itCreated and destroyed per job, invisibly
CompletionYou pollWebhook, or poll
Output URLSigned, expires in 24 hPermanent public URL
FailureCredits are keptCredits are refunded

Two of those are genuine improvements over the generation path, and worth calling out: conversion refunds credits when a job fails, and its output URLs do not expire.


POST /api/v1/convert

Submit a conversion job.

  • Auth required: X-API-Key (a JWT is also accepted)
  • Status: 202 Accepted, or 200 OK on an idempotent replay

Headers

HeaderRequiredNotes
X-API-Keyyesrsk_…
Content-Typeyesapplication/json
Idempotency-KeynoMax 255 characters. See Idempotency

Request fields

FieldTypeRequiredDefaultNotes
file_urlstringyesHTTPS only. Max 2048 characters. Must pass the SSRF guard
template_idstringyesSystem id or usr:<uuid>. Checked synchronously — unknown gives 404
output_formatstringnoThe template's own typeAn assertion, not a conversion target. See below
topicstringno"Summarise and convert the provided source document"Max 2000 characters
extra_contextstringno""Max 20 000 characters
webhook_urlstringnoThe key's configured defaultHTTPS only, SSRF-guarded. Overrides the key's default
modelstringnoRegistry defaultMust be an Anthropic model — see below
notebook_iduuidnoAn ephemeral oneConvert in the context of an existing corpus. Requires member role
filenamestringnoDerived from the URLMax 255 characters, sanitised
metadataobjectno{}Max 4 KB. Echoed verbatim in the webhook — use it for your own correlation id
output_format does not transcode

Output type is a property of the template, not a free parameter. Supplying output_format asserts what you expect; if it disagrees with the template's declared type you get a 422 naming both values. It will not render the template through a different renderer.

To get a different format, choose a different template.

topic is defaulted, but you should still send one

Unlike the generation endpoint — where topic is mandatory — conversion supplies a default. That default is a real sentence rather than an empty string, because topic is also the retrieval query over the ingested file. A specific topic produces a markedly better document.

Only Anthropic models are accepted

Passing an OpenAI model key returns 422. The platform's OpenAI credential is revoked, so those models would fail after charging you. The conversion API rejects them up front rather than inheriting that trap.

Example

curl -X POST https://research.onfire.so/api/v1/convert \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: standup-2026-07-30" \
-d '{
"file_url": "https://files.example.com/standup-2026-07-30.m4a",
"template_id": "investor_deck",
"topic": "Engineering progress and blockers this sprint",
"webhook_url": "https://caller.example.com/hooks/onfire",
"metadata": {"caller_ref": "abc-123"}
}'

Response — 202 Accepted

{
"job_id": "9f1c0f4e-3b7a-4d21-8e56-2c9a4f7b1d80",
"status": "queued",
"template_id": "investor_deck",
"output_type": "pptx",
"credits_charged": 45,
"webhook_url": "https://caller.example.com/hooks/onfire",
"created_at": "2026-07-30T09:14:22.104Z"
}

Errors

StatusCondition
400Malformed body, non-HTTPS URL, oversized field, or a URL that fails the SSRF guard
401Missing, invalid, or revoked API key
402Insufficient credits. No job is created
403Key lacks the convert scope, or you passed a notebook_id you only have viewer access to
404Unknown template_id, or an inaccessible notebook_id (404 rather than 403, so notebook existence is not leaked)
409Idempotency-Key reused with a different body
413The origin advertises a file larger than the cap (100 MB by default)
422output_format contradicts the template, or an unsupported model
429Rate limit exceeded — 60/hour by default, separate from the general limit

Note the split: every pre-flight failure (400, 402, 404, 413, 422) means no job was created and nothing was charged. Failures after acceptance arrive by webhook and are refunded.


GET /api/v1/convert/{job_id}

Poll a job. Scoped to the calling key's user — someone else's job id returns 404.

curl https://research.onfire.so/api/v1/convert/$JOB_ID \
-H "X-API-Key: $API_KEY"
{
"job_id": "9f1c0f4e-3b7a-4d21-8e56-2c9a4f7b1d80",
"status": "done",
"template_id": "investor_deck",
"output_type": "pptx",
"output_url": "https://storage1.onfire.so/document-ai/generated/dd3c3586-…/9f1c0f4e-….pptx",
"output_bytes": 284119,
"source": {
"url": "https://files.example.com/standup-2026-07-30.m4a",
"filename": "standup-2026-07-30.m4a",
"content_type": "audio/mp4",
"bytes": 18234112
},
"error": null,
"error_code": null,
"metadata": {"caller_ref": "abc-123"},
"created_at": "2026-07-30T09:14:22.104Z",
"started_at": "2026-07-30T09:14:24.881Z",
"completed_at": "2026-07-30T09:17:48.902Z",
"webhook": {
"url": "https://caller.example.com/hooks/onfire",
"delivered": true,
"attempts": 1,
"last_status": 200,
"last_attempt_at": "2026-07-30T09:17:49.410Z"
}
}

Statuses

queued → fetching → ingesting → generating → done
│ │ │ │
└─────────┴───────────┴────────────┴──────→ error

done and error are terminal. This is a finer-grained machine than generation's four statuses — you can tell a slow download from a slow model.

Webhook delivery is tracked separately in the webhook object and never changes status. A dead endpoint on your side does not turn a successful conversion into a failure.


Webhooks

When the job reaches a terminal state, the platform POSTs your webhook_url.

Request headers

POST /hooks/onfire HTTP/1.1
Content-Type: application/json
User-Agent: OnFire-DocumentAI-Webhook/1
X-OnFire-Event: conversion.completed
X-OnFire-Delivery: 5b2e8c14-9a03-4f7d-b621-8e4c0d3a5f92
X-OnFire-Timestamp: 1785400462
X-OnFire-Signature: sha256=3f8a…

X-OnFire-Event is conversion.completed or conversion.failed. X-OnFire-Delivery is stable across all retries of one delivery, so use it to de-duplicate.

Payload

{
"event": "conversion.completed",
"job_id": "9f1c0f4e-3b7a-4d21-8e56-2c9a4f7b1d80",
"status": "done",
"output_url": "https://storage1.onfire.so/document-ai/generated/dd3c3586-…/9f1c0f4e-….pptx",
"output_type": "pptx",
"output_bytes": 284119,
"template_id": "investor_deck",
"source_url": "https://files.example.com/standup-2026-07-30.m4a",
"error": null,
"error_code": null,
"metadata": {"caller_ref": "abc-123"},
"credits_charged": 45,
"created_at": "2026-07-30T09:14:22.104Z",
"completed_at": "2026-07-30T09:17:48.902Z",
"delivery_id": "5b2e8c14-9a03-4f7d-b621-8e4c0d3a5f92",
"attempt": 1
}

A failure delivery is the same shape with event: "conversion.failed", status: "error", output_url: null, and a populated error and error_code.

Verifying the signature

The signing secret (whsec_…) is separate from your API key. It is returned once when the key is created, and rotatable afterwards. Leaking it permits forged notifications but grants no API access — which is exactly why it is a distinct secret.

The signature is an HMAC-SHA256 over "{timestamp}.{raw_body}". The timestamp is inside the MAC, so a captured delivery cannot be replayed with a fresh one.

import hashlib
import hmac

def verify(raw_body: bytes, timestamp: str, signature: str, secret: str) -> bool:
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)
Verify against the raw bytes, and reject stale deliveries

Compute the MAC over the raw request body, before any JSON parsing or re-serialisation — re-encoding changes whitespace and key order and will break the comparison.

Reject deliveries whose X-OnFire-Timestamp is more than 5 minutes old. Without that check, signature verification alone does not stop a replay.

Use a constant-time comparison (hmac.compare_digest), never ==.

Retry policy

AttemptDelay after previousTimeout
110 s
230 s10 s
3120 s10 s
  • Success is any 2xx.
  • 3xx is a failure — redirects are not followed. Point the webhook at its final URL.
  • 4xx other than 408 and 429 is permanent — delivery is abandoned after one attempt rather than retrying into a 404 for two and a half minutes.
  • Everything else retries to the ladder above, then abandons.

After abandonment the job remains done and the artifact remains available. Poll GET /api/v1/convert/{job_id} to recover.

If no webhook_url is configured — neither per-request nor on the key — delivery is skipped entirely and polling is the only completion signal.


Idempotency

Send an Idempotency-Key header to make submission safe to retry.

  • Same key, same body → the original job, returned with 200 instead of 202. Nothing is charged twice.
  • Same key, different body409 Conflict. Bodies are compared by hash of the canonical JSON.
  • Keys are scoped per API key, so two callers cannot collide on the same external id.

This matters more here than elsewhere: a conversion charges credits at submission, so a naive retry after a network timeout would bill you twice. Send an idempotency key on every request.


Credits

Charged toThe user who minted the API key — their personal balance
Amount1 credit for ingestion, plus the template's generation cost
WhenSynchronously, before the 202
On failureRefunded

The generation component uses the same formula as the rest of the platform: max(1, ceil(base × model_multiplier)). See credit costs by template.

API-key billing is always personal, never workspace

An API-key request carries no workspace context, so credits are drawn from the key owner's individual balance even if they belong to a workspace with its own pool. This fails closed and is safe — but it is the most likely billing surprise, so check the right balance before concluding you have been charged incorrectly.

Unlike generation, a conversion that fails refunds the credits it charged. Only genuine pre-flight rejections (402, 404, 422) never charge in the first place.


Limits

LimitDefault
Requests60/hour, keyed per user across all their keys
Source file size100 MB
file_url schemeHTTPS only
topic2000 characters
extra_context20 000 characters
metadata4 KB

Both size and rate limits are server-configurable, so read X-RateLimit-* on a 429 rather than assuming the defaults.

Output URLs are public

output_url is a permanent, unauthenticated URL. Its security is the unguessability of the UUID in the path — bucket listing is disabled, but anyone with the link can fetch the file.

Treat a conversion output URL as a capability. Do not put one anywhere you would not put the document itself.


Accuracy of the source matters

The most important failure mode is one that looks like success. A scanned PDF with no text layer, a silent audio file, or a 404 page served with HTTP 200 all produce a source with nothing extractable.

The conversion pipeline explicitly checks for this and fails the job with error_code: "no_content_extracted", refunding your credits. Without that check the model would cheerfully write a document about nothing and report success — so if you see this error, inspect the source URL rather than retrying.