Skip to content

WonderFonT API

openapi.json

WonderFonT API v1

For developers and AI agents working on behalf of a WonderFonT customer. Base URL: https://wonderfont.art/api/v1. The schema is at /api/v1/openapi.json and this guide at /api/v1/docs. Code lives in platform/app/wf_api.py and wf_api_auth.py. The contract is CONTRACTS.md section 7 and master spec 18.

Every identifier and credential below is a placeholder. WS_ID, QUOTE_ID, wf_xxxxxxxx_... and you@example.com are not real.

Versioning

The version is in the path (/api/v1). Within v1 we only add things: new fields, new routes, new optional parameters. A breaking change gets /api/v2. The info.version field of the schema says which build you are talking to.

Authentication

• Public catalog routes need no key.

• Everything else takes Authorization: Bearer wf_<prefix>_<secret>. A key is shown once, when it is created. We store only its hash.

• A key belongs to one person in one workspace. It can do at most what that person can do in the account pages, and only within its scopes. A member's key can never approve or pay.

• Scopes: catalog:read, account:read, account:write, collections:read, collections:write, licenses:read, downloads:create, sites:read, sites:write, mappings:write, transfers:write, team:read, team:admin (owners only), billing:request, billing:execute (owners only), affiliate:read, affiliate:settings, affiliate:payout-request.

• Customer keys never carry ingest, admin or publisher authority. The font workstation uses its own producer header on /api/v1/ingest.

• A key's prefix, a site's public id and a collection's share link are not credentials. None of them authorize anything.

• If the key's person leaves the workspace, the key stops working. A revoked key answers 401.

Errors

Every error has the same shape:

{"error": {"code": "insufficient_scope", "message": "this key lacks billing:execute", "request_id": "3f2a..."}}

When a refusal knows what would fix it, the error also carries next_action, for example {"code": "no_entitlement", "next_action": "buy_desktop", ...} on a download for a family the workspace owns for the web only.

| Code | HTTP | Meaning | |---|---|---| | unauthenticated | 401 | no key, a bad key, or a revoked key | | insufficient_scope | 403 | the key lacks the scope, or the person lacks the role | | wrong_workspace | 403 | the id belongs to another workspace, or you are not a member | | owner_approval_required | 403, or 402 when a spending grant is missing or exhausted | an owner has to do this | | no_entitlement, site_limit, payment_pending | 402 | buy, upgrade or wait (seat_limit is retired: there are no seats) | | quote_expired, version_conflict | 409 | ask for a fresh quote, or reload and retry | | license_not_accepted | 422 | the workspace owner, signed in, accepts the license (an API key never can) | | license_version_stale | 409 | the license changed; the owner accepts the current version | | user_action_required | 400, 422, or 429 when rate limited | fix the request, or a person must act | | provider_unavailable | 503 | try later | | approval_missing, package_invalid, expired, grace_web_only | 422 | see the message (expired: a transfer link that expired or was already used) | | not_found | 404 | no such route or resource |

Every response carries X-Request-Id. If you send one (letters, digits, ., _ and -, up to 64 characters), we echo it back. Quote it when you ask for support.

Rate limits

Each key gets 600 requests a minute, and so does each signed-in session. Anonymous public calls get 120 a minute per IP. Past the limit you get 429 with Retry-After in seconds.

Pagination

List routes take ?limit= and ?cursor=. The answer carries next_cursor, which is null on the last page. The catalog allows at most 100 per page.

Idempotency

Every POST, PUT and DELETE made with a key or a session needs an Idempotency-Key header of 1 to 200 characters. The one exception is POST .../mappings/preview, which changes nothing.

• Sending the same key with the same body replays the stored answer, with Idempotent-Replayed: true.

• Sending the same key with a different body gets 409 version_conflict.

• A request that failed is not stored, so you can retry it with the same key.

Quickstart

Placeholders: $WF=https://wonderfont.art/api/v1, $KEY, $WS_ID, $QUOTE_ID, $SITE_ID, PUBLIC_ID. Steps 1 to 7 run in order with one key that has the scopes asked for in step 2.

1. Discover (no key)

curl "$WF/catalog/families?q=rounded&script=he&limit=10"
curl "$WF/catalog/families/example-family"
curl "$WF/catalog/families/example-family/coverage"
curl "$WF/catalog/families/example-family/pairings"

A family answer includes its styles, axes, languages, web file hashes, pairings and its two one time licenses:

"license": {"desktop": {"cents": 8900},
            "web": {"1": {"cents": 11900}, "5": {"cents": 22900}, "25": {"cents": 49900}},
            "library": "see GET /pricing", "currency": "usd"}

desktop and web are null while a family is not on sale. The desktop license gives the desktop files; a web license (1, 5 or 25 sites) serves the family on your registered sites and has no files to download. They are separate purchases. GET /pricing lists what is sold and the web library subscription (every family, 10, 50 or 100 sites, monthly or annual) as web_library: [{"sites", "cadence", "cents"}], an empty list with "library_on_sale": false until the library is on sale. Search is deterministic. No model runs at request time. There is no trial download endpoint.

2. Connect (a person approves)

curl -X POST "$WF/connect" -H 'Content-Type: application/json' \
  -d '{"name": "Example agent", "scopes": ["catalog:read", "account:read", "billing:request", "billing:execute", "sites:read", "sites:write", "mappings:write"], "storefront": "en"}'
# -> {"code": "ABCD2345", "secret": "<poll secret>", "storefront": "en",
#     "verification_url": "https://wonderfont.art/account/developers/connect?code=ABCD2345",
#     "verification_urls": {"en": "...", "he": "https://wonderfont.art/he/account/developers/connect?code=ABCD2345"}, ...}

WonderFonT has two sites with separate accounts: the English site (wonderfont.art) and the Hebrew site (wonderfont.art/he). Set storefront to he when your person shops on the Hebrew site; the key then belongs to their Hebrew account, and everything it does (quotes, orders, sites, referrals) happens there. Unsure? Show both verification_urls and let them pick. An agent that signs itself up (POST /signup, then POST /signup/verify) passes the same storefront to /signup/verify.

Show verification_url to your person. They sign in with the emailed link on that page. They check that the code matches, pick a workspace and press Allow. Then poll:

curl "$WF/connect/ABCD2345" -H "X-Connect-Secret: <poll secret>"
# pending | denied | expired, or granted + "api_key": "wf_xxxxxxxx_..." (returned exactly once)

An agent cannot skip this step. The person has to own the mailbox. Owner-only scopes are granted only when an owner approves.

3. Quote, and pay or continue with a person

curl -X POST "$WF/workspaces/$WS_ID/quotes" -H "Authorization: Bearer $KEY" \
  -H 'Idempotency-Key: quote-001' -H 'Content-Type: application/json' \
  -d '{"operation": "buy_web", "terms": {"family_id": "FAMILY_ID", "sites": 5}}'

Operations: buy_desktop {"family_id"}, buy_web {"family_id", "sites": 1 | 5 | 25}, upgrade_web {"grant_id", "sites": 5 | 25} (you pay the difference), subscribe {"plan": "web_10" | "web_50" | "web_100", "cadence": "monthly" | "annual"} (only while the library is on sale), plan_change, schedule_downgrade, cancel, reactivate. The operations of the earlier plans (permanent, upgrade_scope, addon_change) and the Personal, Studio and Agency plans answer 422 user_action_required naming the operation to use instead: an old request never takes on a new meaning.

• An owner's key gets an open quote that is good for 15 minutes.

• A member's key gets a request (state: pending_owner) that the owner approves in the account.

• To pay, the key needs billing:execute, and an owner must also have set a spending limit for it on the Developers page. The limit names the allowed purchases (the operations above; a limit cannot name a retired one, and a limit made earlier that names one can never spend on it), the maximum amount, the end date and whether recurring charges are allowed.

curl -X POST "$WF/workspaces/$WS_ID/quotes/$QUOTE_ID/execute" -H "Authorization: Bearer $KEY" \
  -H 'Idempotency-Key: pay-001'
# -> {"state": "submitted", "next_action": "user_action_required", "continuation_url": "https://checkout.stripe.com/..."}

• A purchase that needs a card or a person answers user_action_required with a checkout link for the person. Never send card data to this API.

• If the limit is used up, is missing, or does not cover the operation, the answer is 402 owner_approval_required.

• **The license.** Nothing is bought until the workspace owner has accepted the current Font License Agreement and Terms of Service (GET $WF/pricing names the versions under license). An API key can never accept: sending accept with a key answers 422 license_not_accepted. A key's quote is prepared without it, and execute answers 422 license_not_accepted with the checkout page where the owner accepts it, unless the owner stored the acceptance on this key's spending limit when making it (the two boxes on the Developers page, or accept in POST /workspaces/{id}/grants signed in). A signed-in owner may send "accept": {"license_version": "2", "terms_version": "2", "immediate_delivery": true} with a quote, an approval or an execute. An older version answers 409 license_version_stale. Changes to a license the owner accepted in the current version (a bigger web license, a plan change) reuse that acceptance.

• Two agents spending against one limit can never go past it together.

• To check on a quote: GET $WF/workspaces/$WS_ID/quotes/$QUOTE_ID returns status as open, processing, done or expired.

4. Register a site

The key needs sites:write. Registering takes one site on one of the workspace's web licenses at once: the one you name with "grant_id", else the first with room (web licenses for a family first, then the web library). A desktop license never takes a site (402 no_entitlement). With no free site the answer is 402 site_limit, exactly what the Sites page says. The answer lists licenses, the licenses covering the site. Applying fonts later adds the license that covers each family, if it has room. The site stays pending until you prove you control the address.

curl -X POST "$WF/workspaces/$WS_ID/sites" -H "Authorization: Bearer $KEY" -H 'Idempotency-Key: site-001' \
  -H 'Content-Type: application/json' -d '{"host": "www.example.com"}'
# -> {"id": "SITE_ID", "state": "pending", "hosts": ["example.com", "www.example.com"],
#     "verification": {"dns": {"name": "_wonderfont-site.example.com", "type": "TXT", "value": "wf-site=..."},
#                      "meta": "<meta name=\"wonderfont-site\" content=\"wf-site=...\">"}, ...}

Publish the TXT record (or the meta tag on the home page), then:

curl -X POST "$WF/workspaces/$WS_ID/sites/$SITE_ID/verify" -H "Authorization: Bearer $KEY" -H 'Idempotency-Key: verify-001'
# -> {"state": "active", "verified_by": "dns"}, or {"state": "pending", "result": "not_found"} until the record is live

5. Configure heading and body

Preview first. It checks every family against the workspace's hosted-font rights and shows the diff. Preview needs sites:read and no Idempotency-Key.

curl -X POST "$WF/workspaces/$WS_ID/sites/$SITE_ID/mappings/preview" -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -d '{"roles": {"heading": {"family": "example-family", "weight": 700}, "body": {"family": "example-text", "weight": 400}}}'
# -> {"expected_revision": 0, "roles": {...}, "fallback": {...}, "diff": [...]}

Then apply with the revision you previewed (needs mappings:write):

curl -X POST "$WF/workspaces/$WS_ID/sites/$SITE_ID/mappings" -H "Authorization: Bearer $KEY" \
  -H 'Idempotency-Key: map-001' -H 'Content-Type: application/json' \
  -d '{"expected_revision": 0, "roles": {"heading": {"family": "example-family", "weight": 700}, "body": {"family": "example-text", "weight": 400}}}'
# -> {"revision": 1, "diff": [...]}

• If someone applied a change after you previewed, the answer is 409 version_conflict. Preview again and review.

• A role may also carry release (pin a version), style (normal or italic) and fallback (a font stack).

• A new release never changes your site. GET .../mappings/updates lists newer releases, and you apply one on purpose.

6. Retrieve the embed

curl "$WF/workspaces/$WS_ID/sites/$SITE_ID/embed" -H "Authorization: Bearer $KEY"
# -> {"html": "<link rel=\"stylesheet\" href=\"https://fonts.wonderfont.art/s/PUBLIC_ID.css\">",
#     "css_url": "https://fonts.wonderfont.art/s/PUBLIC_ID.css",
#     "rules": ["h1, h2, h3 { font-family: var(--wf-heading, system-ui), sans-serif; }",
#               "body { font-family: var(--wf-body, system-ui), sans-serif; }"]}

Put the link in the page's <head> and the two rules in your CSS. The stylesheet answers anyone, with no Origin or Referer needed, so pages sent with Referrer-Policy: no-referrer work. The font files it names are signed and served only to a request whose Origin is one of the site's addresses, and only for families a license on that site covers. The public id is not a credential. This keeps other sites from using your fonts; like any web font it is not DRM, a browser keeps what it downloaded.

7. Inspect health

curl "$WF/workspaces/$WS_ID/sites/$SITE_ID/health" -H "Authorization: Bearer $KEY"
curl -X POST "$WF/workspaces/$WS_ID/sites/$SITE_ID/health" -H "Authorization: Bearer $KEY" -H 'Idempotency-Key: check-001'
# -> {"health": {"result": "reachable_with_embed", "host": "example.com", "checked_at": "..."}}

result is one of reachable_with_embed, reachable_no_embed, protected (the site asks for a password: we could not look, the fonts still serve), blocked, unreachable or unverified. The GET returns the last check. The POST checks again now and needs sites:write.

Sites

All under /workspaces/{id}/sites. sites:read reads and sites:write changes. A key made for named sites (site_ids) sees and touches only those. Any other site in the workspace answers 403 insufficient_scope, and such a key cannot register a new site. A site of another workspace, or an unknown id, answers 403 wrong_workspace.

• GET /sites, POST /sites ({"host", "grant_id"?}), GET /sites/{sid} (with the live mapping and available updates).

• POST /sites/{sid}/verify, POST /sites/{sid}/replace ({"host"}, the same slot at a new address; an active site proves the new address first), DELETE /sites/{sid} (frees the slot).

• GET /sites/{sid}/embed, GET /sites/{sid}/health, POST /sites/{sid}/health.

Mappings

• GET /sites/{sid}/mappings: the live revision, its roles and fallbacks.

• POST /sites/{sid}/mappings/preview (sites:read), POST /sites/{sid}/mappings (mappings:write, needs expected_revision).

• GET /sites/{sid}/mappings/history: every revision, newest first, and the site's events.

• POST /sites/{sid}/mappings/rollback (mappings:write, {"to_revision", "expected_revision"?}): a new revision equal to an old one, after the same rights checks.

• GET /sites/{sid}/mappings/updates.

Transfers

Every transfer route needs transfers:write.

• POST /workspaces/{id}/sites/{sid}/transfers ({"email"} or {"workspace_id"}, plus "locale"): offers the site to a client. token and link are in this answer only. A replay answers them as null, and we store only a hash. The site keeps working here until the client accepts.

• GET /workspaces/{id}/sites/{sid}/transfers: each request and how it ended (requested, completed, rejected, expired, failed).

• POST /transfers/review ({"token"}): what the receiving owner sees first. It lists the hosts, the fonts, and whether each of their workspaces has the slot and the fonts.

• POST /transfers/accept ({"token", "workspace_id"?, "expected_revision"?}): only when the key's person is an owner of the receiving workspace, which must be the key's own. A member's key gets 403 owner_approval_required. Nothing is bought here: a missing slot or font says so and points to billing.

• POST /transfers/reject ({"token"}): the client's owner declines, or the sending workspace withdraws.

The token travels in the path. Treat it like a password and do not log it.

Affiliates

These are your own WonderFonT program data. They are never the presets program's data, and the key's workspace does not matter.

• GET /affiliate/link (?to=/en/pricing for one page): your link and deep links to the home page, pricing, the catalog and each published family (affiliate:read).

• GET /affiliate/stats?days=30 (1 to 366): visits, checkouts, orders, revenue and balances for the period (affiliate:read).

• GET /affiliate/ledger?limit=50: your WonderFonT orders, ledger entries and reversals. The buyer is never shown (affiliate:read).

• GET /affiliate/settings, PUT /affiliate/settings ({"reward_mode": "credit"} or "cash", needs affiliate:settings). The preference belongs to you and is shared by every program. Changes have a cooldown, and while it runs the answer is 422.

• GET /affiliate/payout: whether payout details are set up (the destination is masked), plus setup_url.

• POST /affiliate/withdrawals (affiliate:payout-request): answers 202 {"status": "requires_user_action", "continuation_url": ...}. It never approves or sends a payout. A person confirms payout details on that page, and the owner makes WonderFonT payouts.

Other routes in v1

• GET /account, PUT /account/preferences ({"locale": "he"}), GET /account/sessions, DELETE /account/sessions/{id}, GET /workspaces.

• GET /account/favorites, PUT and DELETE /account/favorites/{family}.

• GET and POST /workspaces/{id}/collections, GET /workspaces/{id}/collections/{cid}, POST /workspaces/{id}/collections/{cid}/{rename|delete|share|revoke|add|remove}.

• GET /workspaces/{id}/licenses: one row per live license with grant_id, license_kind (desktop_family, web_family, web_library, or legacy_combined for licenses sold before September 2026, which add legacy: {"tier"}), model_version, family, library, desktop_allowed, hosted_web_allowed, self_host_allowed, sites_allowed, sites_used, releases, and downloads: {"desktop": {"allowed", "reason", "next_action"}, "web": {...}}. There are no seats: every member uses what the workspace owns.

• POST /workspaces/{id}/downloads ({"family": "slug", "kind": "desktop" | "web" | "all"}, kind defaults to desktop). desktop needs a desktop license; web (files to host yourself) and all exist only for earlier licenses that included them. A web license or the library answers 402 no_entitlement with next_action: "buy_desktop" (or "legacy_only" for web). The answer is a signed link that works for 24 hours, checked again when it is used. It never serves trial files.

• GET /workspaces/{id}/team, and POST /workspaces/{id}/team/{invite|remove|role|transfer|domain_claim|...} (needs team:admin, owners only).

• POST /workspaces/{id}/quotes/{qid}/approve. An owner turns a member's request into a priced quote.

• GET /workspaces/{id}/grants, POST /workspaces/{id}/grants (an owner, signed in), and DELETE /workspaces/{id}/grants/{gid}.

• GET /workspaces/{id}/keys, POST /workspaces/{id}/keys (signed in only), and DELETE /workspaces/{id}/keys/{kid}.

Choosing fonts and what you own

These answer AI assistants and apps that help someone choose. The MCP server wraps the same calls.

• POST /api/v1/catalog/recommend (public): a design brief (use, script, sample_text, roles, feel, task, weights, italic, limit) in, a ranked shortlist out, each family with reasons marked verified (read from the font files) or editorial (tags). With a key holding licenses:read, each family also carries access: desktop and web rights the workspace holds and the price of what is missing.

• POST /api/v1/catalog/families/{slug}/preview (public, 30 a minute): {"text": "...", "styles": [{"weight": 700}]} returns PNG previews (base64) drawn with the real web file and every character the family cannot draw. The text is not stored.

• GET /api/v1/workspaces/{id}/access?families=a,b (licenses:read): what is owned for desktop and web, from which license, free site slots, prices of the rest.

• GET /api/v1/workspaces/{id}/subscription (licenses:read): the web library's plan, status, paid-through and grace dates, site use.

• GET /api/v1/workspaces/{id}/orders (billing:request, owners): paid orders, refunds and the receipt page.

A site is live as soon as it is registered (owner 10-01: no proof step, any account may run any address). The pending state and the DNS / meta verification shown in the quickstart above are from before that ruling; verify now only re-checks the site.

Sign in

Continue with Google

or with your email

No password. We'll email you a link.

Accessibility

Text size