Zephrify
Zephrify API
Everything the engine does is available over HTTP, and this page is the
contract. There is also a website: sign up at /signup,
create and revoke API keys at /app/account, and manage your plan
at /app/billing. It does the same things as the calls below.
Authentication
Every endpoint requires a bearer token — including reads. The entity store is the product; an unauthenticated read endpoint would be a free bulk export of it.
Authorization: Bearer zephrify_xxxxxxxxxxxxxxxxxxxx
Get a token by calling POST /register (below) or
by creating a key at /app/account. Tokens are shown once, stored
hashed, and expire after 30 days. Send
Accept: application/json so errors come back as JSON rather than
a redirect.
Base URL
https://app.zephrify.com/api/v1
Signing up
The one call that needs no token. An account is not access:
it can start a subscription and read or erase its own data, and everything
else answers 402 until Stripe confirms payment.
POST /register
{ "name": "Ada", "email": "ada@example.com", "password": "at least 12 characters" }
201
{
"user": { "id": 7, "name": "Ada", "email": "ada@example.com" },
"token": "zephrify_...", // shown once; only its hash is stored
"next": { "subscribe": "/api/v1/billing/checkout", "note": "..." }
}
| Code | When |
|---|---|
403 | Sign-up is closed. |
422 | Invalid input, or the email is already registered. Name up to 120 characters, password 12 to 200. |
429 | Limited per IP, per email address and per day. |
Status codes you will meet
| Code | Meaning |
|---|---|
200 | Fine. |
201 | Created: a site, an account, or a bookmark saved for the first time. |
204 | Done, no body: an unsaved bookmark, an erased account. |
202 | Accepted and queued. A publish or a build runs on a worker, not in the request. |
401 | No token, or a token that is wrong or expired. |
402 | Authenticated, but no active subscription. Deliberately not 403 — a 403 tells a client library to re-authenticate forever. |
404 | Not found, or not yours. Somebody else's site returns 404 rather than 403 so ids cannot be enumerated. |
409 | Nothing malformed; the account is simply in a state that forbids it — at its site limit, or the site is archived or not yet provisioned. |
422 | Validation failed. The response names the field. |
429 | Rate limited. 120 requests a minute per token. |
502 | Account erasure only: the subscription could not be cancelled just now, so nothing was erased. Try again. |
503 | Billing is not enabled, or no plan is configured. |
Entities
The verified store: real-world things with typed attributes and a provenance record. Shared across all customers.
GET /entities list, filterable by type, near, radius, q, limit, cursor
GET /entities/{id} one entity, with its facts and where each came from
An entity is id, type,
name, slug, lat, lon and
facts: an object keyed by fact key, each with
value, source (the adapter), licence
and verified_at. Stale facts are withheld. Lists carry
meta.count, next_cursor, has_more and
attribution: whoever renders the data owes those credits. Pass
next_cursor back as cursor.
Sites
A site is a row: a domain, a market, a scope and a theme. It is not an installation.
GET /sites yours only
POST /sites create one
GET /sites/{site} one site, plus what state its pages are in
PATCH /sites/{site} rename, re-scope, archive or un-archive
DELETE /sites/{site} ARCHIVES. It does not destroy anything.
POST /sites/{site}/publish queue a publish
POST /sites/{site}/build plan, write what is missing, then publish
Creating a site takes a domain, a name, a market and a scope:
POST /sites
{
"domain": "lisbon.example.com",
"name": "Lisbon Cafes",
"market": "cafes",
"scope": { "types": ["poi"], "radius_km": 8 }
}
The scope takes types,
record_types, root_entity_id,
radius_km, descendants,
all_of_type, path_style (slug or
name) and up to ten filters, each
{ "fact", "op", "value", "types" } with op one of
gte gt lte lt eq ne in exists. market,
theme and type names are letters, digits, dashes and
underscores only. types: [] is refused.
The domain must point at us and be verified before it can
publish; the response carries the exact DNS record to add. The storage and
pull zones are created by an operator, so a new site reports
awaiting_provisioning: true until that has happened. At the
account's site limit, creating answers 409. A site carries
routes, archived, stale_urls,
domain_verified and, until verified,
domain_verification (the DNS record).
POST /sites/{site}/build takes an optional
topic and answers 202 with
routes_created, routes_retired,
path_collisions and articles_queued; a scope it
cannot plan is a 422.
Archiving is not deleting. A published URL that vanishes is a 404 for everyone who linked to it, so paths are retired rather than removed. Infrastructure is torn down by an operator command that asks them to type the domain.
Bookmarks
GET /bookmarks your saved places
POST /bookmarks save one
DELETE /bookmarks/{entity} unsave (204, or 404 if not saved)
Saving twice is the same bookmark: 201 the first
time, 200 after. The list carries meta.returned,
total, next_cursor and attribution.
Usage
GET /usage?days=30
Your own figures only: what you hold now, and what work your
account caused in the period. stored counts rows;
metered reads the ledger. They disagree on purpose — regenerating
an article replaces its row but is two pieces of work.
Account
GET /account what is held about you GET /account/export everything, as JSON DELETE /account erase it
Erasing needs a body of { "confirm": "your@email" }
matching the account's own address exactly; otherwise 422.
These are never withheld for non-payment. Access to your own data, and the ability to delete it, is not a feature of a subscription.
Billing
GET /billing current subscription state POST /billing/checkout returns a Stripe Checkout URL GET /billing/portal returns a Stripe billing portal URL
How a subscription actually starts. Call
POST /billing/checkout, send the customer's browser to the
URL it returns, and wait. Reaching the success page proves
nothing — anyone can type a URL. The subscription becomes real
when Stripe's signed webhook reaches us, which is a separate request and
usually lands within seconds.
So: poll GET /billing until
subscribed is true. It returns
billing_enabled, subscribed, status,
cancelled and ends_at. Checkout and portal reply
{ "data": { "url": "..." } }; the portal is 404 if
you have never subscribed. Cards, invoices, VAT and
cancellation all live in Stripe's portal, because those are the places
it is worth not reimplementing.
Rate limits
120 requests a minute, counted per token rather than
per IP, so one customer behind a shared office connection cannot exhaust
another's budget. Every response carries
X-RateLimit-Limit and X-RateLimit-Remaining.
What this API will not do
- Delete a customer's published site on one request.
- Let a caller name their own storage zone — that would let one customer publish over another's.
- Change a site's domain after creation; it moves every published URL.
- Swap generation providers silently, which would change the voice of a site between pages.
What the product does, and what it costs, is at zephrify.com.