REST API
REST API quickstart
A small JSON API over HTTPS. Every request is authenticated with an API key and billed against
your credit balance. Base URL:
https://api.textify.me.
Running locally
wrangler dev in apps/api and swap the base URL for
http://127.0.0.1:8787. In local mock mode the endpoints answer without real provider
keys, so you can develop end-to-end offline.
Authentication
Create an API key from your account, then send it on every request as a bearer token. Keys are shown once at creation — store it somewhere safe (only its hash is kept). New accounts start with 100 free credits a month.
curl -X POST https://api.textify.me/v1/account/keys \
-H "Authorization: Bearer atx_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "label": "my-laptop" }'
The response returns the plaintext key once (prefixed atx_).
Send it on every call using either header — Authorization wins if both are present:
Authorization: Bearer atx_YOUR_API_KEY
# or
X-API-Key: atx_YOUR_API_KEY DELETE /v1/account/keys/<id> and mint a new one.
Fetch a YouTube transcript
1 credit per videoPOST /v1/youtube/transcript
Returns the caption track for a public YouTube video. Captions only — Textify never downloads the video or its audio. Caption-less videos can fall back to provider ASR (billed at the higher rate below).
Billing
Request
curl -X POST https://api.textify.me/v1/youtube/transcript \
-H "Authorization: Bearer atx_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"lang": "en"
}' const res = await fetch("https://api.textify.me/v1/youtube/transcript", {
method: "POST",
headers: {
Authorization: "Bearer atx_YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"lang": "en"
}),
});
const data = await res.json();
console.log(data); import requests
res = requests.post(
"https://api.textify.me/v1/youtube/transcript",
headers={"Authorization": "Bearer atx_YOUR_API_KEY"},
json={
"url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
"lang": "en"
},
)
res.raise_for_status()
print(res.json()) Response
{
"videoId": "dQw4w9WgXcQ",
"title": "Rick Astley - Never Gonna Give You Up (Official Video) (4K Remaster)",
"language": "en",
"availableLanguages": [
"en"
],
"segments": [
{
"start": 0,
"end": 2.5,
"text": "Never gonna give you up"
},
{
"start": 2.5,
"end": 5,
"text": "Never gonna let you down"
}
],
"text": "Never gonna give you up Never gonna let you down"
} Transcribe an audio or video URL
15 credits per audio-hourPOST /v1/transcribe
Runs cloud speech-to-text over an audio or video file you point us at, and returns the full transcript plus timestamped segments. Pass `audio_url` and `duration_sec` (the audio length in seconds, client-measured) — duration is REQUIRED for a remote URL so the per-audio-hour price is fixed up front. Audio ≤10 minutes returns the transcript synchronously (200); longer audio returns 202 with a poll URL. A failed transcription is refunded.
Billing
Request
curl -X POST https://api.textify.me/v1/transcribe \
-H "Authorization: Bearer atx_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"audio_url": "https://cdn.example.com/interviews/ep-42.mp3",
"duration_sec": 372,
"language": "en"
}' const res = await fetch("https://api.textify.me/v1/transcribe", {
method: "POST",
headers: {
Authorization: "Bearer atx_YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
"audio_url": "https://cdn.example.com/interviews/ep-42.mp3",
"duration_sec": 372,
"language": "en"
}),
});
const data = await res.json();
console.log(data); import requests
res = requests.post(
"https://api.textify.me/v1/transcribe",
headers={"Authorization": "Bearer atx_YOUR_API_KEY"},
json={
"audio_url": "https://cdn.example.com/interviews/ep-42.mp3",
"duration_sec": 372,
"language": "en"
},
)
res.raise_for_status()
print(res.json()) Response
{
"jobId": "url_5f3c…",
"status": "completed",
"language": "en",
"durationSec": 372,
"text": "Welcome back to the show. Today we're talking about shipping fast without breaking things.",
"segments": [
{
"start": 0,
"end": 3.1,
"text": "Welcome back to the show."
},
{
"start": 3.1,
"end": 7.8,
"text": "Today we're talking about shipping fast without breaking things."
}
]
} Read text out of an image
1 credit per image or pagePOST /v1/ocr
Extracts text from an image using a vision model (Gemini 2.5 Flash-Lite) — handles photos, screenshots, receipts, and handwriting. Pass an `image_url`, or upload one or more images with `multipart/form-data` (field `image`).
Billing
Request
curl -X POST https://api.textify.me/v1/ocr \
-H "Authorization: Bearer atx_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image_url": "https://cdn.example.com/receipts/2026-07-01.jpg"
}' const res = await fetch("https://api.textify.me/v1/ocr", {
method: "POST",
headers: {
Authorization: "Bearer atx_YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
"image_url": "https://cdn.example.com/receipts/2026-07-01.jpg"
}),
});
const data = await res.json();
console.log(data); import requests
res = requests.post(
"https://api.textify.me/v1/ocr",
headers={"Authorization": "Bearer atx_YOUR_API_KEY"},
json={
"image_url": "https://cdn.example.com/receipts/2026-07-01.jpg"
},
)
res.raise_for_status()
print(res.json()) Response
{
"results": [
{
"index": 0,
"text": "WHOLE FOODS MARKET\nBananas 1.29\nOat milk 4.49\nTOTAL 5.78",
"confidence": 0.98
}
]
} Convert a web page to Markdown
1 credit per fetchPOST /v1/web/markdown
Fetches a public web page, isolates the main article with Readability, and returns clean Markdown — no nav, ads, or clutter. Requests are SSRF-guarded (private IPs and redirect tricks are rejected) and results are cached for 24 hours.
Billing
Request
curl -X POST https://api.textify.me/v1/web/markdown \
-H "Authorization: Bearer atx_YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/blog/shipping-fast"
}' const res = await fetch("https://api.textify.me/v1/web/markdown", {
method: "POST",
headers: {
Authorization: "Bearer atx_YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
"url": "https://example.com/blog/shipping-fast"
}),
});
const data = await res.json();
console.log(data); import requests
res = requests.post(
"https://api.textify.me/v1/web/markdown",
headers={"Authorization": "Bearer atx_YOUR_API_KEY"},
json={
"url": "https://example.com/blog/shipping-fast"
},
)
res.raise_for_status()
print(res.json()) Response
{
"url": "https://example.com/blog/shipping-fast",
"finalUrl": "https://example.com/blog/shipping-fast",
"title": "Shipping Fast Without Breaking Things",
"byline": "Jane Doe",
"markdown": "# Shipping Fast Without Breaking Things\n\nMost teams treat speed and safety as a trade-off. They aren't...",
"wordCount": 812,
"cached": false
} Convert a document to text or Markdown
1 credit per fetchPOST /v1/convert
Extracts text (or Markdown) from a DIGITAL Word/docx, PowerPoint/pptx, spreadsheet/xlsx, or EPUB document. Upload the file with `multipart/form-data` in a `file` field (this endpoint does not fetch URLs). Add `?format=markdown` (default `text`) for Markdown. PDF is NOT supported (501) — pdf.js can't run under the Workers runtime; use the free in-browser PDF tool, or send page images to /v1/ocr. An image-only document with no extractable text returns 422 (refunded) with a pointer to /v1/ocr.
Billing
Request
curl -X POST https://api.textify.me/v1/convert \
-H "Authorization: Bearer atx_YOUR_API_KEY" \
-F "file=@report.docx" \
-F "format=markdown" const form = new FormData();
form.append("file", fileBlob, "report.docx");
form.append("format", "markdown");
const res = await fetch("https://api.textify.me/v1/convert", {
method: "POST",
headers: { Authorization: "Bearer atx_YOUR_API_KEY" }, // do NOT set Content-Type — the browser sets the multipart boundary
body: form,
});
const data = await res.json();
console.log(data); import requests
res = requests.post(
"https://api.textify.me/v1/convert",
headers={"Authorization": "Bearer atx_YOUR_API_KEY"},
files={"file": open("report.docx", "rb")},
data={"format": "markdown"},
)
res.raise_for_status()
print(res.json()) Response
{
"kind": "docx",
"format": "markdown",
"chars": 1284,
"text": "# Q2 2026 Report\n\n## Summary\n\nRevenue grew 18% quarter over quarter..."
} Errors
Errors use standard HTTP status codes and a consistent JSON envelope: a machine-readable
error code and a human-readable message.
Some errors add fields (e.g. balance, needed,
retryAfter).
{
"error": "invalid_url",
"message": "Could not find a YouTube video ID in that URL."
} | Status | error | Meaning |
|---|---|---|
| 400 | invalid_request / invalid_url | The body was malformed or a field failed validation. |
| 401 | unauthorized | Missing, invalid, or revoked API key. |
| 402 | insufficient_credits | Balance too low; body carries { balance, needed }. |
| 404 | not_found / no_captions | No such resource (e.g. a video without captions). |
| 413 / 415 | image_too_large / unsupported_media_type | Upload too big, or the wrong content type. |
| 429 | rate_limited | Too many requests; retry after the Retry-After header. |
| 5xx | provider_error | An upstream provider was temporarily unavailable — safe to retry. |
Rate limits & credit semantics
Metered calls are charged against your balance before the work runs, so you never overspend, and the same ledger backs the website, the API, and the MCP server — one balance, no parallel accounting.
- Balance header.
Successful metered responses include
X-Credits-Balancewith your remaining credits. - Out of credits.
A short balance returns
402 insufficient_creditswith{ "balance", "needed" }and charges nothing. Top up on the pricing page. - Idempotency.
Send an
Idempotency-Keyheader to make a retry safe — the same key replays the original charge and result instead of billing twice. - Rate limits.
A
429 rate_limitedresponse carries aRetry-Afterheader (seconds). Back off and retry. - Free where possible. Cached results (e.g. a repeated YouTube transcript or a page fetched within 24h) don't spend credits.