Skip to content
anymd

Docs

REST API

The anymd REST API v1: authentication, errors, conversion, library, search, usage, keys and admin endpoints with curl, JavaScript and Python examples.

Base URL: https://anymd.cc/api/v1. Everything is JSON unless noted.

The machine-readable spec is at /api/v1/openapi.json and an interactive reference lives at /docs/api/reference. Use the spec for exact response schemas; this page is the guided tour.

Authentication

Send an API key on every request:

Authorization: Bearer amd_…

X-API-Key: amd_… works too. Create keys in Dashboard → API keys or with POST /keys. A key can only do what its scopes allow, and its scopes are capped by its owner's role. See API keys & roles.

A key that is unknown, revoked or expired is rejected with 401 invalid_api_key. It is never silently downgraded to anonymous.

Browser sessions (the amd_session cookie) also work for the dashboard; cookie-authenticated writes must come from the same origin. Agents connecting over MCP can use OAuth 2.1 instead of a key.

Errors

Every error has the same shape and a matching HTTP status:

{
  "error": {
    "code": "forbidden",
    "message": "Missing scope: library:write",
    "required": ["library:write"]
  }
}
Status Typical codes
400 invalid_url, blocked_host, invalid_request
401 unauthorized, invalid_api_key
402 quota_exceeded
408 processing_limit
403 forbidden (missing scope), forbidden_rank, forbidden_self, bad_origin, oauth_key_creation_forbidden
404 not_found, upstream_status
409 revision_conflict, slug_taken, tag_conflict, role_conflict, status_conflict, settings_conflict, billing_owned, idempotency_in_progress
413 too_large
415 unsupported_type
422 empty_content, document_failed, invalid_props, idempotency_mismatch, invalid_request, too_many_tags, invalid_preferences, invalid_setting, plan_managed_by_billing
429 anonymous_limit, rate limits
503 provider_unavailable, oauth_store_unavailable
502 fetch_failed, upstream_status

Read code, not message. Messages are for humans and may change.

Endpoints at a glance

Method Path Scope
POST /convert convert
POST /convert/file convert
GET /videos/:id convert (signed-in callers)
GET /library library:read
GET /library/tags library:read
GET /library/:id library:read
PATCH /library/:id library:write
POST /library/:id/tags library:write
DELETE /library/:id library:write
GET, POST /search library:read
POST /social/search convert (signed-in callers)
GET /usage usage:read
GET /traces, /traces/:id usage:read
GET, POST /keys keys:manage
DELETE /keys/:id keys:manage
GET /me any signed-in caller
GET /account/reading-preferences convert (signed-in callers)
PUT, DELETE /account/reading-preferences keys:manage for API keys and OAuth clients; any signed-in session
POST /billing/checkout, /billing/portal browser session
* /admin/pages…, /admin/blocks, /admin/templates pages:* (admins: builder guide)
* /admin/posts… content:*
GET /admin/users, /admin/users/:id, /admin/users/:id/credentials users:read
PATCH /admin/users/:id (role) users:roles:write
POST /admin/users/:id/status, /admin/users/:id/sessions/revoke users:sessions:write
DELETE /admin/users/:id/keys/:keyId, /admin/users/:id/grants/:grantId users:credentials:write
GET /admin/roles any signed-in caller
GET / PATCH, PUT /admin/settings settings:read / settings:write
GET / POST, DELETE /admin/optouts… optouts:read / optouts:write
GET / POST /admin/credits… credits:read / credits:write
GET /admin/subscriptions…, /admin/billing/diagnostics billing:read
GET /admin/audit, /admin/audit/export audit:read
GET /admin/system/overview, /admin/system/usage, /admin/system/traces… system:read

Convert

POST /convert

Convert a URL. Scope: convert.

Field Type Default Notes
url string required Any public http(s) URL
language string Preferred language, e.g. "vi"
selector string CSS selector for the main content
removeImages boolean saved keepImages, else false Strip image/media references (no credit effect)
frontmatter boolean true Include the YAML frontmatter block
save boolean true Save to your library (needs library:write)
fresh boolean false Skip the cache
expandThread boolean saved, else false Expand the rooted same-author X thread (extra credits, account)
maxThreadPosts integer saved, else 20 Thread posts including the requested post, from 1 to 100
includeComments boolean saved, else false Retrieve comments and replies (extra credits, account)
analyzeImages boolean saved, else false OCR and describe article images (extra credits, account)
maxComments integer saved, else 100 Comment limit, from 1 to 1,000
maxImages integer saved, else 10 Article-image limit, from 1 to 20
maxCredits integer saved, else 100 Per-request budget, from 1 to 1,000
downloadVideo boolean saved, else false YouTube: download the lowest-quality video to the anymd CDN in the background; 20 credits when ready, outside maxCredits (account). See GET /videos/:id
analyzeVideo boolean saved, else false YouTube: also analyze that video with google/gemini-3.8-flash (turns on downloadVideo; downloadVideo: false with analyzeVideo: true is rejected); 10 + 20 credits per started minute when ready, videos up to 60 minutes, outside maxCredits (account)
format "json" | "markdown" markdown returns text/markdown instead of JSON

The JSON response carries the Markdown, the extracted metadata, the library document id (when saved), saved and, when it was not saved, not_saved_reason (not_requested, anonymous, missing_scope when the credential lacks library:write, or library_limit), credits charged, cache status and trace id. See the OpenAPI spec for the exact schema. The X-Anymd-* headers from the URL API are set here too.

For social URLs, kind is facebook, instagram, threads or linkedin; these adapters require an authenticated caller. X is readable anonymously; thread expansion, comments and article-image analysis are opt-in (see deep reading). The response's reading_options shows the effective value of every option and whether it came from the request, your saved preference or the safe default. The response includes credit_breakdown with base, thread, comments and images, plus an enrichment object whose sections expose complete, count, fetchedAt and, when incomplete, reason. Provider failures, the 40-call/55-second processing bounds and item or credit limits produce partial coverage rather than a false complete result.

The base social post costs 10 credits. Each additional X thread post costs 1, each started 20-comment batch costs 10, and each successfully analyzed article image costs 5. Cached reads cost 0. A later signed-in save of the same URL preserves previously saved complete or richer enrichment when the new read is plain or partial.

curl

curl https://anymd.cc/api/v1/convert \
  -H "Authorization: Bearer $ANYMD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://stephango.com/saw"}'

JavaScript (fetch)

const res = await fetch('https://anymd.cc/api/v1/convert', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ANYMD_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({ url: 'https://stephango.com/saw', format: 'markdown' }),
});
if (!res.ok) {
  const { error } = await res.json();
  throw new Error(`${error.code}: ${error.message}`);
}
const markdown = await res.text();

Python (requests)

import os, requests

res = requests.post(
    "https://anymd.cc/api/v1/convert",
    headers={"Authorization": f"Bearer {os.environ['ANYMD_API_KEY']}"},
    json={"url": "https://stephango.com/saw", "save": False},
    timeout=60,
)
res.raise_for_status()
data = res.json()
print(res.headers.get("X-Anymd-Credits"), res.headers.get("X-Anymd-Cache"))

Deep reading options and saved defaults

Every option above resolves with one rule, in every channel (web converter, URL API, REST, CLI, MCP, WebMCP):

explicit request option > your saved reading preference > safe default

The safe default for every credit-consuming enrichment (expandThread, includeComments, analyzeImages) is off: a signed-in request that sets nothing gets the base conversion only. Signing in never turns enrichment on by itself. When an explicit option conflicts with a saved one (for example analyzeImages: true while your saved defaults remove images), the explicit option wins; two explicit conflicting options return 400 invalid_options. Saved defaults never raise your plan's credits, and a request's own maxCredits always wins over the saved cap.

Behavior change (October 2026): signed-in X conversions used to expand same-author threads automatically. They no longer do. Send expandThread: true or save it as a default to keep the old behavior.

GET /account/reading-preferences

Returns { preferences, saved, updated_at, defaults, limits }. saved: false means nothing is stored and the safe defaults apply. Scope: convert; signed-in callers only.

PUT /account/reading-preferences

Who may change them: any signed-in session (you, in the dashboard or browser, whatever your role), or an API key or OAuth client holding keys:manage. Saved defaults decide what every other key, OAuth client and the web converter may spend when they leave an option out, so a Convert only or Convert + library key, or an OAuth client with the default scopes, can read them but not change them (403 forbidden): use a key with Everything my role allows, or the dashboard. Every change is written to the audit log with the auth kind, key id and the changed fields.

A partial update: send only the fields to change; the rest keep their saved value. Unknown fields and out-of-range values are rejected (never silently clamped) with 422 invalid_preferences and a details list naming each field and its allowed range. analyzeImages requires keepImages.

Field Type Default Range / notes
expandThread boolean false Extra credits
maxThreadPosts integer 20 1–100
includeComments boolean false Extra credits
maxComments integer 100 1–1,000
keepImages boolean true No extra credits; same as images=1 / removeImages: false
analyzeImages boolean false Extra credits; needs keepImages
maxImages integer 10 1–20
maxCredits integer 100 1–1,000; cap per conversion including the base price
downloadVideo boolean false YouTube reads also download the lowest-quality video to the anymd CDN in the background; 20 credits per video, charged when ready. See GET /videos/:id
analyzeVideo boolean false Needs downloadVideo: each downloaded video (up to 60 minutes) is analyzed by google/gemini-3.8-flash; 10 + 20 credits per started minute, charged when ready
curl -X PUT https://anymd.cc/api/v1/account/reading-preferences \
  -H "Authorization: Bearer amd_…" -H "Content-Type: application/json" \
  -d '{"expandThread": true, "maxThreadPosts": 30}'

DELETE /account/reading-preferences

Removes the saved defaults; the safe defaults apply again. Same rule as PUT (any signed-in session, or keys:manage for keys and OAuth clients); audited. The same settings live in the dashboard under Account → Deep reading defaults.

GET /videos/:id

With downloadVideo on (sent with the request, or saved as a default), a YouTube read returns at once and downloads the lowest-quality MP4 to the anymd CDN in the background. The convert response then carries video_download ({ id, status, check_url, … }, or { status: "skipped", reason } with unavailable or quota_exceeded), and the Markdown ends with a Video download section naming the job id and this endpoint. Poll the job every 15–30 seconds (Retry-After is set while it runs):

curl https://anymd.cc/api/v1/videos/vid_… -H "Authorization: Bearer amd_…"
{ "id": "vid_…", "status": "ready", "video_id": "dQw4w9WgXcQ", "quality": "360p", "bytes": 11534336,
  "cdn_url": "https://cdn.anymd.cc/videos/youtube/dQw4w9WgXcQ/18.mp4", "credits": 20, "error": null, "check_url": "…" }

status is queued, downloading, ready or failed; error names the failure (for example video_unavailable, video_too_large, provider_unavailable) and a failed job costs nothing. Jobs belong to the account that started them: another account gets 404 not_found. Scope: convert; signed-in callers only. MCP: get_video_download.

With analyzeVideo also on, the job carries analysis (null when no analysis was requested) and duration_seconds. After the download is ready, keep polling (Retry-After stays set) until analysis.status is ready or failed:

"analysis": { "status": "ready", "model": "google/gemini-3.8-flash", "credits": 90,
  "markdown": "### Summary\n…\n### Timeline\n- **00:00** …", "error": null }

analysis.status is queued, running, ready or failed; analysis.error is for example video_too_long, quota_exceeded, analysis_failed or download_failed, and a failed analysis costs nothing.

POST /convert/file

Upload a file as multipart form data in the file field. PDF, DOCX, XLSX, XLS, ODS, ODT, CSV and images (JPEG, PNG, WebP, SVG). Max 20 MB. Scope: convert.

curl https://anymd.cc/api/v1/convert/file \
  -H "Authorization: Bearer $ANYMD_API_KEY" \
  -F "file=@report.pdf"
with open("report.pdf", "rb") as f:
    res = requests.post(
        "https://anymd.cc/api/v1/convert/file",
        headers={"Authorization": f"Bearer {os.environ['ANYMD_API_KEY']}"},
        files={"file": f},
        timeout=120,
    )

Files cost 3 credits (images 5). See Billing & credits.

Library

Every signed-in conversion is saved to your private library, one document per source URL. Converting the same URL again refreshes it.

GET /library

?limit=20&before=<ts>&domain=&kind=&tag= returns { items, next_cursor }, newest first. Pass next_cursor as before to get the next page. Filter by domain (e.g. github.com), kind (e.g. youtube) or tag.

curl "https://anymd.cc/api/v1/library?limit=20&kind=youtube" \
  -H "Authorization: Bearer $ANYMD_API_KEY"

tag matches whole tags: tag=ai finds documents tagged ai, never rai or ai-safety. Repeat it (tag=ai&tag=rag) or comma-separate it (tag=ai,rag) to require every tag, up to 10. Tag values are normalized like stored tags.

curl "https://anymd.cc/api/v1/library?tag=rag&tag=research" \
  -H "Authorization: Bearer $ANYMD_API_KEY"

GET /library/tags

Your tags with how many documents carry each, most used first: { "items": [{ "tag": "rag", "count": 12 }, …] }. ?limit= defaults to 100 (maximum 500).

curl "https://anymd.cc/api/v1/library/tags?limit=50" \
  -H "Authorization: Bearer $ANYMD_API_KEY"

GET /library/:id

Returns the document. Add ?format=md for the raw Markdown:

curl "https://anymd.cc/api/v1/library/doc_…?format=md" \
  -H "Authorization: Bearer $ANYMD_API_KEY" -o saved.md

PATCH /library/:id

Replace all tags. Tags are lowercased and limited to letters, digits, - and _; a document keeps at most 20.

curl -X PATCH https://anymd.cc/api/v1/library/doc_… \
  -H "Authorization: Bearer $ANYMD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"tags":["research","rag"]}'

POST /library/:id/tags

Edit tags without replacing the rest: { "add"?: [...], "remove"?: [...] }, or { "set": [...] } to replace them all (set can't be combined with add/remove; [] clears). Tags are normalized like PATCH. Returns { id, tags } with the resulting tags.

An edit that would leave more than 20 tags fails with 422 too_many_tags and changes nothing; nothing is silently dropped.

curl -X POST https://anymd.cc/api/v1/library/doc_…/tags \
  -H "Authorization: Bearer $ANYMD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"add":["rag"],"remove":["todo"]}'

DELETE /library/:id

Deletes the document and its search embeddings.

GET /search?q=… or POST /search with the same fields as JSON. Scope: library:read. Costs 0 credits.

Param Default Notes
q required Query text
mode hybrid hybrid, bm25, fulltext or semantic
limit 10 Up to 50
fanout off 1 rewrites the query into variants and fuses all results. Pro and above
decide off 1 lets Jev break a near-tie among the top results. Pro and above

On Free, fanout and decide are ignored and listed in the response's gated array, for example "gated": ["fanout"]. The search itself still runs.

curl -G https://anymd.cc/api/v1/search \
  -H "Authorization: Bearer $ANYMD_API_KEY" \
  --data-urlencode "q=how does reciprocal rank fusion work" \
  -d mode=hybrid -d fanout=1 -d limit=10
const res = await fetch('https://anymd.cc/api/v1/search', {
  method: 'POST',
  headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({ q: '"exact phrase" AND cloudflare', mode: 'fulltext', limit: 20 }),
});
const { hits } = await res.json();

How the modes, fan-out and Jev work, plus the response shape: Library & search.

POST /social/search with { "platform": "all" | "x" | "facebook" | "instagram" | "threads" | "linkedin", "query": "…", "cursor"?: "…" }. Scope: convert, and the caller must have an account. Each page that returns results costs 10 credits (100 on LinkedIn); all searches every platform and costs the sum for the platforms that returned results. Empty pages and provider failures are free. Pass next_cursor back as cursor for the next page.

curl https://anymd.cc/api/v1/social/search \n  -H "Authorization: Bearer $ANYMD_API_KEY" \n  -H "Content-Type: application/json" \n  -d '{"platform":"linkedin","query":"ai agents"}'

Response shape, errors and per-platform notes: Social search.

Usage and traces

Endpoint Returns
GET /usage?days=30 { plan, quota, totals, daily, events }
GET /traces?limit=50&cursor=… { items, next_cursor }: recent traces, newest first
GET /traces/:id One trace with its spans (fetch, extraction, cache, save…)

Every conversion response carries its trace id in X-Anymd-Trace. When something is slow, the spans show which step was slow.

Keys

Scope: keys:manage.

# Create (the full key is returned once, in `key`)
curl https://anymd.cc/api/v1/keys \
  -H "Authorization: Bearer $ANYMD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name":"ci-pipeline","preset":"convert-only","expires_in_days":90}'

# List
curl https://anymd.cc/api/v1/keys -H "Authorization: Bearer $ANYMD_API_KEY"

# Revoke
curl -X DELETE https://anymd.cc/api/v1/keys/key_… -H "Authorization: Bearer $ANYMD_API_KEY"

Body: { name, preset?, scopes?, expires_in_days? }. Presets and scopes: API keys & roles.

Me

GET /me returns { id, email, name, role, plan, scopes } for the caller. Handy for checking what a key can do.

Billing

POST /billing/checkout with { "plan": "pro" | "scale", "interval": "month" | "year" } returns { url } for a Polar checkout. POST /billing/portal returns { url } for the customer portal. Both need a browser session, not an API key. See Billing & credits.

Admin: pages and posts

These endpoints power the AI-operable CMS. They need role-granted scopes (pages:*, content:*).

  • Pages: /admin/blocks, /admin/templates, /admin/pages and friends. Admins will find the full guide in the dashboard under Pages → Builder guide.
  • Posts: GET/POST /admin/posts with { slug?, title, markdown, excerpt?, tags?, category?, cover_url?, seo_title?, seo_description? }; GET/PATCH/DELETE /admin/posts/:id; POST /admin/posts/:id/publish with { "publish": true | false }.

Admin: system administration

The control plane for owners and admins: users, credentials, settings, opt-outs, credits, billing (read-only), audit and system health. The same services back the MCP admin tools, so scopes, rank rules, idempotency and audit are identical. Which role holds which scope: API keys & roles.

Conventions:

  • Pagination. Lists return { items, next_cursor }; pass next_cursor back as ?cursor=. limit is bounded.
  • Timestamps in filters accept epoch milliseconds or ISO 8601.
  • Idempotency. Mutations accept an Idempotency-Key header (or idempotencyKey in the body). A retry returns the first result with replayed: true; reusing a key for a different change is 422 idempotency_mismatch; a retry that arrives while the first attempt is still running is 409 idempotency_in_progress (retry after a second).
  • Concurrency. Send expectedRole or expectedVersion to get 409 instead of overwriting a change made meanwhile.
  • Audit. Every change records the acting user, credential, route (via: api:<METHOD route>), request id and a minimal diff.

Users and credentials

Endpoint Body or query Scope
GET /admin/users ?search=&role=&plan=&status=&createdAfter=&lastLoginAfter=&cursor=&limit= users:read
GET /admin/users/:id users:read
PATCH /admin/users/:id { role, expectedRole? } users:roles:write
POST /admin/users/:id/status { status: "suspended" | "active", reason, expectedStatus? } users:sessions:write
GET /admin/users/:id/credentials users:read
POST /admin/users/:id/sessions/revoke { sessionHandle? } users:sessions:write
DELETE /admin/users/:id/keys/:keyId users:credentials:write
DELETE /admin/users/:id/grants/:grantId users:credentials:write

Plans follow the billing provider: PATCH /admin/users/:id with a plan field returns 422 plan_managed_by_billing. Grant credits instead. Nobody can change their own account here, and admins only act on lower-ranked accounts (403 forbidden_rank).

curl -X PATCH https://anymd.cc/api/v1/admin/users/usr_… \
  -H "Authorization: Bearer $ANYMD_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"role":"editor","expectedRole":"user"}'

Settings and opt-outs

Endpoint Body or query Scope
GET /admin/settings Returns { settings, version, updated_at, updated_by } settings:read
PATCH /admin/settings { patch: { key: value | null }, expectedVersion? } settings:write
PUT /admin/settings The patch itself (original form, no version check) settings:write
GET /admin/optouts ?search=&cursor=&limit= optouts:read
POST /admin/optouts { domain, reason? } optouts:write
DELETE /admin/optouts/:domain optouts:write

Credits and billing

Endpoint Body or query Scope
GET /admin/credits ?userId=&state=active|expired|revoked&source=&cursor=&limit= credits:read
POST /admin/credits { userId, credits, reason, source?, expiresAt?, recurring? } plus Idempotency-Key (required) credits:write
POST /admin/credits/:id/revoke { reason } credits:write
GET /admin/subscriptions ?status=&plan=&userId=&cursor=&limit= billing:read
GET /admin/subscriptions/:id Includes consistency between the user plan and the subscription billing:read
GET /admin/billing/diagnostics Provider config (secrets as present/missing only), webhook outcomes and failures, plan drift billing:read

POST /admin/credits answers 201 for a new grant and 200 with replayed: true for a retry. Grants from billing orders cannot be revoked here (409 billing_owned). How grants count: an active grant raises their allowance. By default a grant expires at the end of the current month (UTC), or at the end of next month when fewer than 7 days of this month remain; a grant without recurring is a one-time pool: its credits are spent once over its whole lifetime, so whatever one month uses is gone the next (5,000 granted on the 26th with 3,000 used that month leaves at most 2,000 for next month). Pass recurring: true (with or without expiresAt) for credits that renew in full every month; an expiresAt later than the default without recurring: true is 422. On Pro and Scale, credits are used in this order: the plan's included credits, then grants, then paid overage. A grant covers usage from the moment it is granted onward and is not a refund: overage reported before it stays billed. Example: on a plan with 10,000 included credits, a 5,000-credit grant made when the account had used 12,000 this period keeps the next 5,000 credits (12,000 to 17,000) off the overage bill; the 2,000 used before the grant stay billed. Positions are counted within the current period of a monthly subscription, or the calendar month (UTC) otherwise.

curl https://anymd.cc/api/v1/admin/credits \
  -H "Authorization: Bearer $ANYMD_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: outage-2026-10-usr_…" \
  -d '{"userId":"usr_…","credits":500,"reason":"October incident"}'

Audit and system

Endpoint Query Scope
GET /admin/audit ?action=user.*&actorUserId=&target=&targetType=&since=&until=&cursor=&limit= audit:read
GET /admin/audit/export Same filters; NDJSON, up to 5,000 rows (X-Anymd-Count, X-Anymd-Truncated) audit:read
GET /admin/system/overview system:read
GET /admin/system/usage ?days=7&channel=&kind= (days 1–30) system:read
GET /admin/system/traces ?sort=recent|slowest&status=&kind=&userId=&since=&cursor=&limit= (since at most 30 days ago; slowest ranks the newest 5,000 traces in the window) system:read
GET /admin/system/traces/:id system:read

GET /admin/roles lists role templates, scopes, key presets and plans for any signed-in caller.

Updated 2026-10-10 · Edit on GitHub

# Markdown
Open .md