The API
The reference for the HTTP interface. Everything the browser does, it does through this.
Everything the browser does, it does through this API. There is no privileged path into the service that a script cannot also take — the interface is a client, deliberately, because an interface not exercised by its own front end rots without anybody noticing.
Base URL and hosts
| Purpose | URL | In front of it |
|---|---|---|
| Public site and API | https://geodzk.com | nothing — this is the internet |
| Admin plane | admin.geodzk.com | the operator's own gate, then the role check |
/api/admin/* answers only on the admin host. Anywhere else those paths return 404, not 403: the admin surface is not discoverable from the main site. That gate sits in front of the role check and neither implies the other.
Authentication
Two mechanisms, both resolving to the same thing — an account. A key is for a script; a session cookie is for a browser.
curl -s https://geodzk.com/api/links -H "X-API-Key: $GEODZK_KEY"
- A key is not ambient, so it cannot be replayed by a hostile page and needs no CSRF token. Keys are stored as a digest; the secret is shown once and is not recoverable.
- A session cookie is ambient, so every state-changing request from a browser must also carry the session's
X-CSRF-Token. The token is a property of the session, not of the account. - There are two cookies in the whole service: the session, and a short-lived handle for an in-flight passkey ceremony. Both are
HttpOnly,SameSite=Lax, andSecurewhenever the browser's leg was HTTPS. - Minting keys, adding passkeys and changing two-factor settings are session-only. A key can never mint another key, however broadly scoped — otherwise a narrow credential could widen itself.
Roles and scopes
A role belongs to a person and is the ceiling on what they can ever do. A scope belongs to a credential and is a subset of what its owner may do. The effective permission of any request is the intersection of the two, computed from the account's current role — so demoting somebody narrows every key they already hold in the same instant, with nothing to revoke and no cache to expire.
| Scope | Grants |
|---|---|
links:read | List your links and their click counts |
links:write | Create, edit and delete your links |
groups:read | List your groups |
groups:write | Create, rename and delete your groups |
keys:read | List your API keys |
keys:write | Create and revoke your API keys |
users:read | List accounts (admin plane) |
users:write | Create, change the role of, and delete accounts (admin plane) |
docs:read | Read this service's documentation as text (the pages are public anyway) |
notes:read | Read your own notes to the developer |
notes:write | Write, close and delete your own notes to the developer |
| Role | Means |
|---|---|
viewer | Read-only. Can see their own links and groups; cannot change anything. |
editor | Full control of their own links and groups. The normal role. |
admin | Everything an editor can do, plus managing accounts on the admin plane. |
| Role | Scopes it can grant |
|---|---|
viewer | docs:read groups:read keys:read keys:write links:read notes:read notes:write |
editor | docs:read groups:read groups:write keys:read keys:write links:read links:write notes:read notes:write |
admin | docs:read groups:read groups:write keys:read keys:write links:read links:write notes:read notes:write users:read users:write |
An unknown role grants nothing. That is not padding: a future migration that forgets a role string must lock the account down rather than open it up.
Conventions
- Request and response bodies are JSON. Send
Content-Type: application/json. - Timestamps are ISO 8601, UTC, second resolution. They sort correctly as strings, which is why they are stored that way.
codeis the full key as it appears after/l/— either a bare root code or a namespaced one. Paths that take a code accept the slash, soDELETE /api/links/alice/readingis right.0in any limit field means no limit, never none allowed.- A successful delete returns
204with no body. - Response fields are added, never removed or repurposed. Read the ones you know and ignore the rest.
Errors
One shape, everywhere. Branch on error.code; show error.message.
{"error": {"code": "quota_exceeded",
"message": "You have used 500 of your 500 links."}}
No error path returns a stack trace, a SQL fragment or a library's internal message. The one exception to the shape is deliberate: anything under /l/ is followed by a person in a browser, so a dead short link answers with a page rather than JSON.
| Status | error.code | Meaning, and what to do |
|---|---|---|
| 400 | bad_channel | A print was asked for with a tag the link does not have. Register the tag first: PUT /api/links/{code}/channels. |
| 400 | bad_range | A date or number range was not one. Fix the range. |
| 400 | bad_request | Malformed request the route never saw. |
| 400 | bad_size | A print size other than label, card, poster or cut. Use one of the four. |
| 400 | bad_status | An unknown browser-support status. |
| 400 | bad_version | A browser version that is not a version. |
| 400 | challenge_expired | The ceremony took too long. Start again. |
| 400 | challenge_missing | No ceremony is open for this browser. Start again from the beginning. |
| 400 | name_required | A name field was blank. |
| 400 | not_a_key | That credential is a session, and this asks about a key. |
| 400 | passkey_rejected | The authenticator's answer did not verify. Try again, or fall back to a password. |
| 401 | mfa_invalid | Wrong six-digit code, or wrong backup code. |
| 401 | unauthorized | No credential, or one that does not resolve. Sign in, or send a valid X-API-Key. Never retry the same credential. |
| 403 | account_disabled | The account has been disabled by an administrator. Nothing to retry. Ask them. |
| 403 | csrf_failed | A browser request arrived without its X-CSRF-Token. |
| 403 | forbidden | Your role does not allow this, or a key was used where only a browser may act. Stop. This is the answer, not a hint to try another route. |
| 403 | insufficient_scope | Your role allows it; this key was not granted it. Use a key that holds the scope. A key can never widen itself. |
| 403 | invite_invalid | The invitation is unknown, spent or expired. Ask for a new one; do not retry this one. |
| 403 | invite_required | Sign-up here needs an invitation. |
| 403 | mfa_required | A second factor has to be enrolled before anything else. Finish enrollment. |
| 403 | not_unlocked | That badge treatment has not been unlocked on this account. |
| 403 | root_namespace_forbidden | The bare /l/{code} space is admin-only here. Create the link in your own namespace instead. |
| 403 | scope_exceeds_role | You asked for a key scope above your own role. Ask for less. |
| 403 | signup_closed | Sign-up is closed on this instance. |
| 404 | link_parked | The code is held by the operator rather than in use. Ask for it: POST /api/requests. |
| 404 | not_found | No such thing — or the admin plane, asked on the wrong host. Do not probe. The two cases are deliberately indistinguishable. |
| 405 | method_not_allowed | That path exists; that verb does not. |
| 409 | already_decided | That request has already been answered. |
| 409 | already_requested | You have an open request for that code. |
| 409 | case_only_rename | The new name differs from the old one only in case. |
| 409 | code_available | You asked to release a code that is not retired. |
| 409 | code_collision | A generated code collided; the service gave up rather than loop. |
| 409 | code_retired | Tombstoned: deleted or parked. Codes here are never reissued. Pick another. An admin can release it if it matters. |
| 409 | code_taken | That code is already in use. Pick another, or omit code for a random one. |
| 409 | conflict | A conflict raised without a more specific slug. |
| 409 | feedback_full | The operator's inbox is full. Try later, or tell them another way. |
| 409 | group_exists | You already have a group with that name. |
| 409 | group_quota_exceeded | This account is at its group quota. |
| 409 | instance_full | The whole instance is at its link ceiling. Tell the operator. |
| 409 | key_history_full | This account has minted its lifetime maximum of keys. Revoking does not help: a revoked row is kept so its secret can never come back. |
| 409 | key_limit_reached | You hold as many live keys as the instance allows. Revoke one you no longer use. |
| 409 | last_admin | The service refuses to be left with no administrator. |
| 409 | last_credential | Removing that would leave the account with no way in. |
| 409 | no_enrolment | There is no second factor enrolled to act on. |
| 409 | not_published | That group is not published, so it has no public slug. |
| 409 | passkey_exists | That authenticator is already registered. |
| 409 | passkey_limit_reached | As many passkeys as the instance allows. |
| 409 | quota_exceeded | This account is at its link quota. Delete something, or ask for more. |
| 409 | self_delete | An admin may not delete or disable their own account. |
| 409 | signup_full | Every place on this instance is taken. |
| 409 | slug_taken | Two collections were published at once and wanted the same address. Publish again; the allocator picks the next free address. It used to report this as 'you already have a group called None' — the wrong constraint, the wrong owner, and the publish had silently not happened. |
| 409 | stale_index_version | if_version no longer names the masked index's current blob. GET it again and reapply your change on top of the new version. |
| 409 | stale_order | An arrangement named a link that is not in the orbit, or named one twice. Reload the orbit and arrange it again; nothing was changed. |
| 409 | too_many_open_notes | As many open notes as one account may have at once. Close some with PATCH /api/notes/{note_id}; the ceiling is on open ones only. |
| 409 | too_many_open_requests | As many open requests as are allowed at once. |
| 409 | user_exists | That username is taken. |
| 409 | user_limit_reached | The instance is at its account ceiling. |
| 409 | username_held | A former owner still holds that name, and it has not lapsed. |
| 409 | username_reserved | That name is held back by the service. |
| 409 | username_retired | It belonged to a deleted account and is kept out of use. |
| 409 | username_unavailable | That name is not available. |
| 410 | link_disabled | An administrator disabled the link, or the owner's account is disabled. |
| 410 | link_exhausted | At its click limit, with no fallback set. |
| 410 | link_expired | Past its expiry, with no fallback set. |
| 410 | link_retired | The link was deleted. That code will never work again. |
| 413 | payload_too_large | A body above 64 KB. Send less. Nothing was saved. |
| 422 | acknowledgement_required | A destructive action needs its acknowledgement field. |
| 422 | invalid_request | Failed validation. The message names the field. |
| 429 | rate_limited | Too many of these, too quickly. Wait the number of seconds in the message. A retry loop here is the one thing that really hurts a small machine. |
| 429 | username_cooldown | A username was changed too recently to change again. The message says when. Nothing to retry before then. |
| 500 | code_exhausted | No free random code could be found. Tell the operator. |
| 500 | internal_error | A fault at our end. Logged on the box in full, and opaque to you. Do not retry in a loop. |
| 500 | masked_index_damaged | This account's masked-index row is missing or not the fixed width. Tell the operator. Nothing was changed, and the answer names no account. |
| 500 | slug_exhausted | No free collection slug could be found. Tell the operator. |
| 503 | busy | The box could not do this piece of work just now. Wait a few seconds and try again. Nothing is wrong with the account or the link, and nothing you sent was over a limit. Two things answer this way. A password check is deliberately expensive here, so only a couple run at a time and the rest are turned away rather than queued — the redirects keep the machine. And a link with a max_clicks limit is not served while its click cannot be recorded: for that link the count is the promise rather than a statistic, and serving it unrecorded is how a single-use code gets spent twice. |
Rate limits and ceilings
The instance-wide buckets exist because a per-subject limit cannot defend the box on its own: an attacker picks the subject and can send a different one every time. For sign-in that subject is a username and each attempt costs a full password hash by design; for collections it is a slug, and a spread across many slugs is exactly how a flood defeats a cache. Both buckets must allow a request, and the global one is charged only after the per-subject one allows — so one noisy account cannot lock everybody else out.
| What | Limit | Counted per |
|---|---|---|
| Link creation | 20 / 60s | per account |
| Group creation | 10 / 60s | per account |
| Edits to links and groups | 120 / 300s | per account |
| Credential writes — keys, passkeys, two-factor | 10 / 300s | per account |
| QR and print renders | 120 / 60s | per account |
| Sign-in, invitations, two-factor answers | 10 / 300s | per username or token |
| The same, instance-wide | 120 / 60s | one bucket |
| Username availability checks | 60 / 60s | per account |
| Published collection renders | 240 / 60s | per slug |
| The same, instance-wide | 3000 / 60s | one bucket |
| Notes to the operator | 3 / 3600s | per account |
| Masked-link writes — create, retarget, delete, index saves | 20 / 60s | per account |
| The same, instance-wide — index saves are not counted here | 600 / 60s | one bucket |
| Request body | 64 KB | per request, declared or not |
| Following a short link | never limited | — |
Following a short link is never throttled. Throttling the product to protect against nothing is not a trade worth making.
| Ceiling | Default | Set by |
|---|---|---|
| Links per account | 500 | dashboard, and per account |
| Groups per account | 25 | dashboard, and per account |
| Links shown on one collection | 500 | environment |
| Live API keys per account | 20 | environment |
| Keys an account may ever mint | 100 | environment |
| Passkeys per account | 10 | environment |
| Accounts on the instance | 100 | dashboard |
| Links on the instance | 50,000 | dashboard |
| Masked links on the instance | 5,000 | environment |
| Target URL length | 2,048 characters | environment |
| Code length | 5 random, up to 64 chosen | environment |
| Session lifetime | 14 days | environment |
| Invitation lifetime | 14 days | per invitation |
| Wait between username changes | 90 days | environment |
| Shortest password | 12 characters | environment |
Lowering a ceiling never deletes anything. PATCH /api/admin/settings answers with a warnings array when a new ceiling is below current usage, and creation simply stops until things are back under it.
Who may call what
| Access | Means |
|---|---|
| public | No credential at all. This is the internet-facing surface. |
| key or session | An API key or a signed-in browser. A key needs the scope named beside it. |
| session only | A signed-in browser only. These change what an account is, so a key — however broadly scoped — is refused with 403 forbidden. |
| admin plane | Answers only on the admin host, and then only to the admin role. Anywhere else these paths return 404, so the surface is not discoverable. |
The public surface
| Endpoint | Needs | What it does |
|---|---|---|
GET /l/{code} | public | Follow a short link in the root namespace. 302, never 301, so a link can be retargeted without fighting caches. |
GET /l/{namespace}/{code} | public | Follow a short link in an account's namespace. The normal shape. namespace is the owner's username. |
GET /x/{code}/{key} | public | Follow a masked link, compatible mode. The key travels in the URL and this service decrypts the target before redirecting — see POST /api/masked. A wrong key, a strict-mode code, a disabled or missing code, and a decrypted target outside http/https all answer the identical 404 a missing short link gives. |
GET /x/{code} | public | Follow a masked link, strict mode. As shipped, this service never sees the key (a changed page could send it home; /docs/masked says so): the response is a small page carrying only the ciphertext, and a nonced inline script decrypts it in the browser — with WebCrypto AES-GCM against the key in location.hash, which is never sent here — before navigating with location.replace(). A missing code, a compatible-mode code, and a disabled one all answer the identical 404 a missing short link gives. |
GET /c/{slug} | public | A published collection, as a page. ?favorites=1 shows only starred links. ?theme=system|dark|light|hc-dark|hc-light|eink picks the appearance; a footer of links switches it. |
GET /c/{slug}/sheet | public | The same collection, laid out for a printer. A code beside each link, capped so a sheet stays a sheet. |
GET /embed/{slug} | public | The framable variant of a collection. The only page here that may be put in a frame. Takes ?theme= in its src; shows no appearance links of its own. |
GET /api/public/collections/{slug} | public | A published collection, as JSON. Access-Control-Allow-Origin: *. Click counts are never included. |
GET /docs | public | This documentation. |
GET /docs/browsers | public | Which browser engines this has been looked at in, which build, and when. |
GET /docs/{slug} | public | One documentation page. Public HTML. No credential, on any of them. |
GET /healthz | public | Liveness and version. {"status":"ok","version":"…"} |
GET / | public | The sign-in page, or your own dashboard once signed in. |
GET /print/l/{code} | links:read | One of your links as something to print. ?size=label|card|poster|cut. A QR is not a secret, but which codes you hold is not public, so somebody else's code answers exactly as a missing one. |
The redirect is the whole product, so its order of resolution is worth stating exactly. Referrer-Policy: no-referrer on every hop means the destination never learns which short link sent somebody, or that geodzk was involved at all.
| Condition | Result |
|---|---|
| No such code | 404 |
| The owner's account is disabled | 410 link_disabled |
| Moderated by an admin | 410 link_disabled — never follows a fallback |
active_from is in the future | 404 — an unreleased link must not leak that it exists |
Past expires_at | 302 to fallback_url, else 410 link_expired |
click_count has reached max_clicks | 302 to fallback_url, else 410 link_exhausted |
max_clicks is set and the click cannot be recorded | 503 busy, with Retry-After. Not fallback_url — the link is fine and the service is not, so it is retryable rather than finished. An uncapped link redirects anyway; only a counted one is held back |
| Otherwise | 302 to target_url, and the click counter increments |
A published collection as JSON, for anybody putting one on their own page:
{"slug":"reading-list","name":"Reading List","owner":"alice","count":2,
"truncated":false,"limit":500,
"links":[{"code":"alice/lwn","short_url":"https://geodzk.com/l/alice/lwn",
"target_url":"https://lwn.net/","title":"LWN",
"is_favorite":true}]}
- Sent with
Access-Control-Allow-Origin: *, because a published collection is public by definition. - Click counts and last-access times are not included. Those are the owner's operational data.
truncatedistruewhen the collection holds more thanlimitlinks and only the firstlimitcame back. A client paginating oncountalone would otherwise conclude it had everything.- Cached as
max-age=0, s-maxage=30: a shared cache absorbs a flood while the browser always revalidates, so unpublishing takes effect on the next request rather than half a minute later. The owner pressed Unpublish and expects it to mean now.
Links
| Endpoint | Needs | What it does |
|---|---|---|
GET /print/l/{code} | links:read | One of your links as something to print. ?size=label|card|poster|cut. A QR is not a secret, but which codes you hold is not public, so somebody else's code answers exactly as a missing one. |
POST /api/links/{code}/duplicate | links:write | Copy a link under a new code, in the same orbit. same orbit, target, title, window and cap |
GET /api/links | links:read | List your links, with click counts. this account's links; ?group_id= narrows to one orbit |
POST /api/links | links:write | Shorten a URL. {"url":"https://…","title":"optional","code":"optional"} |
PATCH /api/links/{code} | links:write | Change a link you own. {"target_url":"https://…"} and the other editable fields |
DELETE /api/links/{code} | links:write | Delete a link, permanently. permanent — see Care, below |
GET /api/links/{code}/when | links:read | When a link is followed — 168 cells, weekday × hour, never who — and the readings. Released only above thresholds: null cells until 20 visits, and a cell below 3 reads null ("few") rather than a number. A cell of one is a person. readings is a list of {key, text, note}: quiet-since, weekday share, busiest hours, channel share, refusals — arithmetic on stored counters, same thresholds. ?further=1 adds further: the crowd's likely UTC offset, channel ranges, next week — guesses about everyone at once, refused below their own floors, never stored. |
PUT /api/links/{code}/channels | links:write | Register the tags a link may be followed through. {"channels":["poster","sheet"]}. A click arriving as ?via=poster is counted against the tag — the tag you put in the link, never who followed it. An unregistered tag counts nothing. /print/l/{code}?via=poster puts the tag in the QR. |
GET /api/links/{code}/qr | links:read | A QR for one of your links, as SVG. Black on white, always — a theme-aware QR is a QR that fails half the time. |
| Field | Type | Notes |
|---|---|---|
url | string, required | http/https only, at most 2,048 characters, not on the denylist |
code | string | 1–64 of A-Za-z0-9_-. Omit for a random 5-character one |
namespace | string | Omit for your own. "", "root" or "-" asks for the bare root space, which is admin-only by default |
title | string | A label for your own list; never shown to a visitor |
group_id | int | Must be a group you own |
active_from | ISO 8601 | Not live until then |
expires_at | ISO 8601 | Dead after then |
max_clicks | int ≥ 1 | Burn after N. A link with this set answers 503 rather than redirecting if the click cannot be recorded — the count is the promise, so it is held back rather than spent unrecorded |
fallback_url | string | Where an expired or used-up link goes instead of a dead end |
keep_tracking | bool | Default false. true stores url and fallback_url exactly as given, skipping the tracking-parameter scrub described below |
{"code":"alice/hello","namespace":"alice","local_code":"hello",
"short_url":"https://geodzk.com/l/alice/hello",
"target_url":"https://example.com/hello","title":null,
"group_id":null,"group_name":null,"is_favorite":false,
"created_at":"2026-08-16T12:45:25+00:00","click_count":0,
"last_click_at":null,"active_from":null,"expires_at":null,
"max_clicks":null,"fallback_url":null,"disabled":false,
"removed_tracking":[]}
Tracking parameters come off url and fallback_url before either is stored — utm_*, fbclid, gclid, and the rest of a short, fixed list of identifiers that route on no server anywhere. removed_tracking in the response names what came off, sorted, and empty when nothing did. Send "keep_tracking":true to store the URL exactly as given instead. ref is deliberately never touched: it selects a revision on some sites, so scrubbing it would silently retarget the link rather than clean it.
PATCHeditstarget_url,title,group_id,is_favorite,active_from,expires_at,max_clicks,fallback_urlandkeep_tracking. A resubmitted URL that matches what is already stored is left alone rather than re-scrubbed — an unedited field is not a retarget, sokeep_trackingonly has anything to say when the URL is actually changing. The code, the namespace, the click count and the owner are not editable through the API at all. Retargeting writes an audit row.DELETEremoves the link and retires its code permanently. A code that has been printed, pasted or turned into a QR outlives the row behind it, so letting somebody else claim it would silently retarget every one of those. Deleting an account retires all of its codes the same way.- The QR route encodes the short URL, never the target. A QR of the destination cannot be retargeted, cannot be expired, counts no clicks, and prints somebody's destination onto the object in a form any camera reads.
- It answers with
svgandprint_svg: one drawn for a screen, one larger and asking for a higher error-correction floor, for something that will be folded or left on a window. - The print route holds one card at 45×21mm, 85×55mm or 148×210mm, or
cut: eight butted edge to edge on A4 with trim marks, so one cut serves two cards. A card carries the code, the short URL, an optional title and an optional date — never a click count and never a name. - Both are owner-scoped: a code you do not own answers exactly as one that does not exist, because the difference would confirm it is there. Both are rate-limited despite being reads, because they turn a request into real arithmetic without touching a row.
Masked links
| Endpoint | Needs | What it does |
|---|---|---|
POST /api/masked | links:write | Create a masked link. The ciphertext is sealed by the client before it is sent; this service stores it and cannot open it. {"code":"...","ciphertext":"mask:v1:...","mode":"strict|compatible","manage_hash":"..."}. The code and the manage hash are the client's own choice too, computed before this request is sent. The seal is app/masked.py's: a 32-byte key; AES-256-GCM with a 12-byte random nonce prepended and the 16-byte tag appended; AAD geodzk/masked-target/v1 followed by the code, as UTF-8; the UTF-8 target NUL-padded to the smallest of 256, 512, 1,024, 2,048, 4,096, 8,192 bytes that holds it; the whole encoded as mask:v1: followed by padded urlsafe base64. manage_hash is the SHA-256 hex of the manage token (its own random bytes, never derived from the key), which the client keeps. The full link carries the key as unpadded base64url: /x/{code}#k={key} for strict, /x/{code}/{key} for compatible. |
PUT /api/masked/{code} | links:write | Replace a masked link's ciphertext. The mode is fixed at creation. {"ciphertext":"mask:v1:...","manage_token":"..."}. A wrong token and a missing code answer the identical 404 — same status, same body. |
DELETE /api/masked/{code} | links:write | Delete a masked link. {"manage_token":"..."}, in the body — the same carrier PUT uses. A wrong token and a missing code answer the identical 404. |
GET /api/masked-index | links:read | Read this account's masked-index blob. {"blob":"base64...","version":"sha256 hex of the raw bytes"}. Every account's blob is the same fixed width whether or not it has ever been used, and this service can never open it — see DECISIONS.md. |
PUT /api/masked-index | links:write | Replace this account's masked-index blob. {"blob":"base64...","if_version":"..."}. 422 unless the decoded blob is exactly the padded width; 409 stale_index_version if if_version is not the current blob's hash, so two tabs cannot silently erase each other's links. |
A masked link's target is sealed before this request is ever sent — the client generates a key and a code, encrypts the target under both, and this service is handed only the result. Every field is the client's own choice: the code (drawn the same way codes.new_masked_code draws one), the ciphertext, and the mode. Nothing here decrypts anything, and nothing here learns a target, in either mode.
manage_hashissha256(manage_token)hex, computed once by the client at creation. Only the client holdsmanage_tokenitself; retargeting or deleting presents the token, never the hash, and this service compares it in constant time against the stored hash.- A masked link has no owner. Authority to retarget or delete it is
manage_tokenalone — not an account, not a session, not the link's own key, which every recipient holds and which would make the link revocable by anybody it was sent to. PUTandDELETEboth answer the identical404— same status, same body — for a code that does not exist and for the right code with the wrong token. The two are not told apart.- Nothing on these three routes writes an audit row or logs a line naming the code or the caller. An audited "who created or changed code X" is exactly the attribution a masked link exists to remove.
- What masked links are for, the two modes, and their limits, in prose: Masked links.
Groups and collections
| Endpoint | Needs | What it does |
|---|---|---|
POST /api/groups/{group_id}/fork | groups:write | A new orbit derived from this one, every link copied in order. {"name":"optional"}; refused whole when there is no room for every copy |
GET /api/groups | groups:read | List your groups. list groups |
POST /api/groups | groups:write | Create a group. {"name":"Reading"} |
PATCH /api/groups/{group_id} | groups:write | Rename, publish or unpublish a group. Publishing answers with the slug the collection now lives at. |
PUT /api/groups/{group_id}/order | groups:write | Arrange the order an orbit is published in. {"codes":["alice/party","alice/map"]} — the whole orbit, in order; [] clears it. Unarranged orbits list favorites first, then newest. Moving a link to another orbit forgets its place. |
DELETE /api/groups/{group_id} | groups:write | Delete a group. The links in it survive; they simply stop being grouped. |
{"visibility":"public"}allocates a slug on first publish and keeps it stable afterwards, so unpublishing and republishing does not break a URL you already shared — and renaming the group keeps the same slug, so a title is free to change without claiming more URL space. The reply carriespublic_urlandembed_url.- Deleting a group that was ever published retires its slug permanently. The allocator hands out the first free name matching the title, so without this Alice could publish "Reading" at
/c/reading, share it, delete the group — and Bob's own "Reading" would then be given that slug, silently pointing everyone holding Alice's URL at Bob's collection. - Groups are capped because every group can be published and every published group is a distinct unauthenticated URL that renders HTML — and distinct URLs are how a flood defeats a cache.
GET /api/mecarriesgroup_quotaso a client can show the allowance before it is reached.
Keys
| Endpoint | Needs | What it does |
|---|---|---|
GET /api/keys | keys:read | List this account's API keys — never their secrets. list this account's keys (never their secrets) |
POST /api/keys | keys:write | Mint an API key. Session-only: a key that could mint a key could widen itself. |
GET /api/keys/self | key or session | What this key is called, what it may do, when it expires. Any credential, no scope — a key may always ask about itself. |
PATCH /api/keys/{key_id} | keys:write | Rename a key. A label, not a capability. |
DELETE /api/keys/{key_id} | keys:write | Revoke a key. The row is kept, so the same secret can never be reissued. |
{"name":"backup script","scopes":["links:read","docs:read"],
"expires_in_days":90,"agent":false}
- Two independent gates: the requested scopes may not exceed your role, and may not exceed what the calling credential currently holds.
docs:readarrives ticked on the form because it reads these public pages and touches no account data. Every other scope is left for a person to choose, and unticking this one is a click."agent": trueaddsagent_briefto the reply: a plain-text briefing to hand over instead of a bare secret. It names the header, the base URL, the scopes and the endpoints they reach, the lifetime, whose account it acts as, where a secret may not be put, that a429or409is not fixed by retrying — and it points at AI instructions for the rest. The briefing contains the key, so the whole block is the secret.- The agent flag is metadata: authentication never reads it, so an agent key is exactly as powerful as the same key without it. What it changes is that the key is labeled agent-held in the list, in the audit row, and in the admin plane's per-account summary.
GET /api/keys/selfanswers a different question fromGET /api/keysand needs no scope: which key am I holding? Without it an agent could not tell its owner "the key I am using is called reserch bot and expires in nine days" — the sentence that gets a typo noticed. A browser session gets a400pointing atGET /api/keys, because a session is not a key.PATCHtakes a name and changes nothing else. A name is a label: correcting one never requires the key to be reissued — which for an agent key would mean redistributing a credential over a spelling mistake. It is session-only for a reason of its own: the list of names is what an owner reads to spot a key they do not recognize, so a stolen key able to relabel itself could hide in that list.
Documentation
| Endpoint | Needs | What it does |
|---|---|---|
GET /api/docs | docs:read | Every documentation page: slug, title, summary. the manual, as text |
GET /api/docs/{slug} | docs:read | One documentation page, as text. ?format=json wraps it as slug, title, text. |
The same pages you are reading. They are public HTML as well, so a refusal here names the URL that needs no credential rather than stopping at no.
Account and session
| Endpoint | Needs | What it does |
|---|---|---|
POST /api/login | public | Sign in with a username and password. May answer with a second-factor challenge rather than a session. |
POST /api/logout | public | End this session. |
GET /api/me | key or session | Who this credential is, and what it may do. |
GET /api/signup/state | public | Whether accounts can be made here, and on what terms. |
POST /api/signup | public | Redeem an invitation and create the account. |
GET /api/username/available | key or session | Is a username free. |
POST /api/username | session only | Change your username. Your old namespace is held rather than released — see Accounts. |
POST /api/facets/variant | session only | Choose which treatment your badge wears. No scope, deliberately: a script has no business picking somebody's badge. |
{"ok":true,"username":"alice","role":null,
"mfa_required":true,"mfa_enrolment_required":false}
roleisnulluntil the sign-in is actually finished. A password alone should not reveal whether it opens an admin account. Read the role fromGET /api/meonce the second factor has passed.- A session carrying
mfa_requiredormfa_enrolment_requiredauthenticates nothing: every endpoint but the two-factor ones answers401until the second factor is presented, and it expires in ten minutes rather than the usual fortnight. GET /api/mecarries identity, scopes, the link and group quotas, and the rename state — enough for a client to show an allowance before it is reached.- Sign-up is invitation-only or closed, and cannot be opened by an environment variable. An invitation is single-use, carries the role it will grant, and cannot grant admin.
Changing a username is not a cosmetic edit — a username is a namespace — so the endpoint is shaped accordingly.
| What the rename does | |
|---|---|
| Moves | the account name, and every link whose namespace was the old name: its code, its namespace, and its click rows |
| Retires | every old /l/<old-name>/<code> as a tombstone, and the old name itself |
| Leaves alone | root-namespace links, collection slugs, API keys, passkeys, the pass phrase, the authenticator, and every historical audit row |
acknowledge: trueis required, not a formality: without it the call is refused with a message naming the URLs that will stop working and the wait that will start.- The wait is enforced in the
WHEREclause of the single statement that performs the rename, so it holds across sessions, devices, keys, reloads and two requests arriving at once. Admins are bound by it too. - The first change is always free — the common case is an invited account fixing a name somebody else chose.
409 code_collisionmeans some of the account's codes already exist or are retired under the new name, and nothing was changed. Half a rename would be an account split across two namespaces.
Two-factor and passkeys
| Endpoint | Needs | What it does |
|---|---|---|
GET /api/mfa/state | public | How far an in-flight sign-in has got. |
POST /api/mfa/verify | public | Answer a six-digit challenge. |
POST /api/mfa/recovery | public | Answer with a backup code instead. |
POST /api/mfa/enrol/begin | public | Start enrolling an authenticator app. Reachable from a first sign-in as well as from a session. |
POST /api/mfa/enrol/complete | public | Finish enrolling, and receive ten backup codes. |
GET /api/mfa | session only | Your enrollment state and settings. |
POST /api/mfa/settings | session only | Ask for a code when signing in with a passkey too. |
POST /api/mfa/recovery/regenerate | session only | Ten new backup codes; the old ten stop working. |
POST /api/passkeys/login/begin | public | Start a passkey sign-in. |
POST /api/passkeys/login/complete | public | Finish a passkey sign-in. |
GET /api/passkeys | session only | List the passkeys on this account. |
POST /api/passkeys/register/begin | session only | Start adding a passkey. |
POST /api/passkeys/register/complete | session only | Finish adding a passkey. |
PATCH /api/passkeys/{cred_id} | session only | Rename a passkey. |
DELETE /api/passkeys/{cred_id} | session only | Remove a passkey. |
- Starting an enrollment changes nothing. The candidate secret is staged separately and promoted only once a code from it has been proved, so an abandoned re-enrolment leaves a working authenticator exactly as it was.
- A half-signed-in session may only enroll an account that has no authenticator yet. Replacing a confirmed one needs a fully authenticated session, or a pass phrase alone would be enough to walk past the second factor.
- Codes are single-use within their window — the accepted step is recorded, so the same code cannot be replayed for the rest of its thirty seconds — and drift tolerance is one step either side, no more.
- Passkey sign-in is usernameless: no credential list is sent, so the authenticator offers the account itself and the endpoint cannot be used to test whether a username exists.
- A passkey skips the six-digit step only when the assertion actually verified the user with a PIN or a biometric. One that merely proved presence is a single factor and gets the same second step a pass phrase does. An account can demand both anyway.
- Ceremony challenges are single-use, expire in five minutes, and are looked up by a handle in a short-lived cookie — never supplied in the request body, where a caller could choose their own.
- Registering does not ask for a name first: collecting one up front put a dialog between the user's gesture and the browser's credential call, which cost the ceremony its user activation on mobile. The reply carries the new id and name so a client can offer to rename it straight after.
Asking the operator for something
| Endpoint | Needs | What it does |
|---|---|---|
GET /api/requests | key or session | The code releases you have asked for, and their answers. |
POST /api/requests | key or session | Ask an admin to release a retired code. |
POST /api/feedback | key or session | Send the operator a note. Stored with no attribution at all — see Privacy. |
POST /api/browser-reports | key or session | Volunteer which browser engine and version you are on. Typed by a person, never detected. Counted, never attributed. |
Three channels from an account holder to the operator, all of which exist because the alternative was a dead end. They split on attribution: a code request names its requester, because the answer is a code released into that person's namespace. Feedback and browser reports record nobody.
- A code request is accepted only for a code you could actually claim if it were released — your own namespace, or the root namespace if you are an admin. Anybody else's namespace is refused before anything is looked up, because the answer would otherwise confirm that another account's code exists.
- Feedback requires an account and records no author. Those two are separable and this is where they separate: authentication is what makes the rate limit real, while the stored row identifies nobody. There is no author column, no audit entry, and the note carries only the day it arrived, in no order — because the audit log does record who did what and when, the nightly backups record what arrived each day, and anything finer than either would be a correlation handle against them.
- A browser report is a counter, not a log: one row per engine and version holding a works count, a problems count and a day. No per-submission row exists at all, so two increments are indistinguishable from one person reporting twice.
- Reports change nothing by themselves. Promoting an engine to tested by user is a separate, audited action in the admin plane: taking a stranger's word is a decision somebody makes, not a thing that happens.
- Where anonymity gives up more than a note does, and the form says so: a sentence is drawn from an unbounded space and an engine-and-version pair is not. On a small instance a rare engine narrows to whoever is known to run it.
- Several reports travel in one request, because the limit is per submission. A limit with no way to batch would bound how much somebody can tell you rather than how much they can waste.
The admin plane
Answers only on the admin host, and only to an account with the admin role. Every write is audited.
| Endpoint | Needs | What it does |
|---|---|---|
GET /api/admin/users | users:read | List accounts. |
POST /api/admin/users | users:write | Create an account. |
PATCH /api/admin/users/{user_id} | users:write | Change a role or a quota, or disable an account. |
DELETE /api/admin/users/{user_id} | users:write | Delete an account. Refuses on the last admin, and on yourself. |
GET /api/admin/invites | users:read | Outstanding and spent invitations. |
POST /api/admin/invites | users:write | Issue an invitation. |
DELETE /api/admin/invites/{invite_id} | users:write | Revoke an unused invitation. |
GET /api/admin/settings | users:read | The deployment scope: ceilings and switches. |
PATCH /api/admin/settings | users:write | Change the deployment scope. |
GET /api/admin/metrics | users:read | What this box is doing, and what it costs. |
GET /api/admin/audit | users:read | The audit log. |
POST /api/admin/audit/purge | users:write | Purge audit rows older than a cutoff. The fact of the purge is itself recorded. |
GET /api/admin/tombstones | users:read | Retired codes. |
DELETE /api/admin/tombstones/{code} | users:write | Release a retired code for reuse. |
POST /api/admin/moderate/{code} | users:write | Disable or re-enable one link. A moderated link never follows a fallback. |
POST /api/admin/moderate-masked/{code} | users:write | Disable or re-enable one masked link. The reply carries no target and no owner: this service holds neither. Both /x/ routes then answer the standard 404 — a strict-mode page already cached at the edge keeps resolving until s-maxage runs out. |
GET /api/admin/notes | users:read | Every account's notes to the developer, with their author. |
PATCH /api/admin/notes/{note_id} | users:write | Close anybody's note with an outcome they will see. |
GET /api/admin/requests | users:read | Code-release requests from account holders. |
PATCH /api/admin/requests/{request_id} | users:write | Answer a code-release request. |
GET /api/admin/feedback | users:read | Notes sent by account holders. |
PATCH /api/admin/feedback/{feedback_id} | users:write | Mark a note read. |
DELETE /api/admin/feedback/{feedback_id} | users:write | Delete a note. |
GET /api/admin/browsers | users:read | Volunteered browser reports, counted. |
PATCH /api/admin/browsers/{slug} | users:write | Set an engine's support status. |
DELETE /api/admin/browsers/{slug}/reports | users:write | Clear the counts for one engine. |
- The account list deliberately returns no links, groups or keys — only counts. The admin plane manages who exists, not what they have.
- There is no admin rename endpoint: it would be a second path to the same state with a weaker guard on it. A
usernamefield in a user patch is ignored rather than applied, and an operator who needs to rename somebody does it on the box. - Retired usernames and retired collection slugs have no release. A short code is scarce —
summitis worth reclaiming — so releasing one is a real decision with a warning attached. A username is not scarce, so there is nothing to weigh against the harm of handing somebody a namespace that has already had links published under it. - Granting a code request releases the code in the same action, because marking it granted and forgetting to release it leaves the requester looking at an answer they cannot use. That is safe only because a request is accepted for the requester's own namespace, so the release opens the code to exactly one person.
- Moderation is the one place an admin looks another account's link up on purpose. Running a shared service on your own domain means being able to answer an abuse report, and pretending otherwise makes the capability undocumented rather than absent. So it is narrow — one code, no listing, no search — and every use writes an audit row naming the admin, the code and the target they saw. That is true of ordinary links only: a masked link is not in the table this reads, and its counterpart can turn one off or on and nothing more. It cannot say where a masked link goes or who made it, so its answer and its audit row carry the code and the decision, and no target.
- The honest caveat: the audit log is not a listing, but it is not blind either. It records a creation with its code and a retarget with its new URL, so an admin reading far enough back can learn codes and some targets without touching moderation. That is kept deliberately — retargeting is the change most worth reconstructing after the fact — so the accurate claim is that no route enumerates another account's links, not that an admin cannot learn of them. An operator who wants neither can purge the log. Masked links are not in it: nothing is audited when one is made, changed or deleted, and the only masked row it can hold is an admin turning a code off or on.
The metrics endpoint has three properties worth knowing before reading a chart drawn from it. Every point is the peak of its bucket rather than the mean, because averaging is precisely how a spike disappears and spikes are the reason anybody looks. The limits come from the samples, so a percentage is against the ceiling that actually applied. And a rate needs two readings, so the first sample in a window carries no figure and a long gap is left as a gap rather than drawing a plateau over an outage.
Stability
This is a small service shared with people its operator knows, not a public platform, and it is versioned accordingly: there is no /v1/ prefix and there will not be one until something outside this repository depends on it.
- Response fields are added, not removed or repurposed. A client that reads the fields it knows and ignores the rest keeps working.
error.codevalues are stable. Branch on those, never onerror.message, which is prose written for a person and gets reworded.- Paths are stable —
/l/{code}most of all, since every short link ever issued depends on it. That is why the interface claims/,/api/*,/c/…,/embed/…,/docs/…and/healthz, and redirects live under/l/alone. - There is no served OpenAPI document, and every route is kept out of the schema. An interactive schema browser on a public host is a map of the attack surface for no benefit to the handful of people using this. If a real integration ever needs a machine-readable schema, the right move is to generate it into the repository as a file rather than serve it live.
What is deliberately not here
Absences a client author would otherwise waste time looking for.
- No analytics endpoints. A click count and a last-access time per link, and nothing else. The web server's own access log is off for the same reason.
- No email, anywhere. There is no email column in the schema and no mail is ever sent. That is a privacy decision with a real cost: recovery is single-use codes or an operator reset, not a link in an inbox. Invitations are delivered by a person.
- No pass phrase reset endpoint, and no two-factor reset endpoint. Both are commands on the box, for the same reason: the operation is not something a request should be able to reach, and anybody who can run it already holds the database file.
- No bulk or search endpoints across accounts. Every query that touches account data takes an account id, which is a property of the storage layer rather than a rule route authors have to remember.
- No pagination, except on the audit log. Quotas bound every other collection to a size that fits in one response. The exception is a public collection page, whose ceiling is about the cost of one anonymous request rather than about storage — so it must not move when a quota does.
- No webhooks or callbacks. The service makes no outbound HTTP requests at all, which is why a shortened target can never be used to make it fetch something.
- No search endpoint. The largest read here is bounded by the link quota, so a client fetches the list and filters it — which is what the dashboard does.
The same page as text, for a program: GET https://geodzk.com/api/docs/api — needs a key holding docs:read.