Tested in Blink and WebKit.

Which browsers, and when · How all of this works

geodzkdocs

reading plane · What this is, what it keeps, and how to drive it without a browser.

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

PurposeURLIn front of it
Public site and APIhttps://geodzk.comnothing — this is the internet
Admin planeadmin.geodzk.comthe 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, and Secure whenever 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.

ScopeGrants
links:readList your links and their click counts
links:writeCreate, edit and delete your links
groups:readList your groups
groups:writeCreate, rename and delete your groups
keys:readList your API keys
keys:writeCreate and revoke your API keys
users:readList accounts (admin plane)
users:writeCreate, change the role of, and delete accounts (admin plane)
docs:readRead this service's documentation as text (the pages are public anyway)
notes:readRead your own notes to the developer
notes:writeWrite, close and delete your own notes to the developer
RoleMeans
viewerRead-only. Can see their own links and groups; cannot change anything.
editorFull control of their own links and groups. The normal role.
adminEverything an editor can do, plus managing accounts on the admin plane.
RoleScopes it can grant
viewerdocs:read groups:read keys:read keys:write links:read notes:read notes:write
editordocs:read groups:read groups:write keys:read keys:write links:read links:write notes:read notes:write
admindocs: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.
  • code is 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, so DELETE /api/links/alice/reading is right.
  • 0 in any limit field means no limit, never none allowed.
  • A successful delete returns 204 with 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.

Statuserror.codeMeaning, and what to do
400bad_channelA print was asked for with a tag the link does not have. Register the tag first: PUT /api/links/{code}/channels.
400bad_rangeA date or number range was not one. Fix the range.
400bad_requestMalformed request the route never saw.
400bad_sizeA print size other than label, card, poster or cut. Use one of the four.
400bad_statusAn unknown browser-support status.
400bad_versionA browser version that is not a version.
400challenge_expiredThe ceremony took too long. Start again.
400challenge_missingNo ceremony is open for this browser. Start again from the beginning.
400name_requiredA name field was blank.
400not_a_keyThat credential is a session, and this asks about a key.
400passkey_rejectedThe authenticator's answer did not verify. Try again, or fall back to a password.
401mfa_invalidWrong six-digit code, or wrong backup code.
401unauthorizedNo credential, or one that does not resolve. Sign in, or send a valid X-API-Key. Never retry the same credential.
403account_disabledThe account has been disabled by an administrator. Nothing to retry. Ask them.
403csrf_failedA browser request arrived without its X-CSRF-Token.
403forbiddenYour 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.
403insufficient_scopeYour role allows it; this key was not granted it. Use a key that holds the scope. A key can never widen itself.
403invite_invalidThe invitation is unknown, spent or expired. Ask for a new one; do not retry this one.
403invite_requiredSign-up here needs an invitation.
403mfa_requiredA second factor has to be enrolled before anything else. Finish enrollment.
403not_unlockedThat badge treatment has not been unlocked on this account.
403root_namespace_forbiddenThe bare /l/{code} space is admin-only here. Create the link in your own namespace instead.
403scope_exceeds_roleYou asked for a key scope above your own role. Ask for less.
403signup_closedSign-up is closed on this instance.
404link_parkedThe code is held by the operator rather than in use. Ask for it: POST /api/requests.
404not_foundNo such thing — or the admin plane, asked on the wrong host. Do not probe. The two cases are deliberately indistinguishable.
405method_not_allowedThat path exists; that verb does not.
409already_decidedThat request has already been answered.
409already_requestedYou have an open request for that code.
409case_only_renameThe new name differs from the old one only in case.
409code_availableYou asked to release a code that is not retired.
409code_collisionA generated code collided; the service gave up rather than loop.
409code_retiredTombstoned: deleted or parked. Codes here are never reissued. Pick another. An admin can release it if it matters.
409code_takenThat code is already in use. Pick another, or omit code for a random one.
409conflictA conflict raised without a more specific slug.
409feedback_fullThe operator's inbox is full. Try later, or tell them another way.
409group_existsYou already have a group with that name.
409group_quota_exceededThis account is at its group quota.
409instance_fullThe whole instance is at its link ceiling. Tell the operator.
409key_history_fullThis account has minted its lifetime maximum of keys. Revoking does not help: a revoked row is kept so its secret can never come back.
409key_limit_reachedYou hold as many live keys as the instance allows. Revoke one you no longer use.
409last_adminThe service refuses to be left with no administrator.
409last_credentialRemoving that would leave the account with no way in.
409no_enrolmentThere is no second factor enrolled to act on.
409not_publishedThat group is not published, so it has no public slug.
409passkey_existsThat authenticator is already registered.
409passkey_limit_reachedAs many passkeys as the instance allows.
409quota_exceededThis account is at its link quota. Delete something, or ask for more.
409self_deleteAn admin may not delete or disable their own account.
409signup_fullEvery place on this instance is taken.
409slug_takenTwo 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.
409stale_index_versionif_version no longer names the masked index's current blob. GET it again and reapply your change on top of the new version.
409stale_orderAn arrangement named a link that is not in the orbit, or named one twice. Reload the orbit and arrange it again; nothing was changed.
409too_many_open_notesAs 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.
409too_many_open_requestsAs many open requests as are allowed at once.
409user_existsThat username is taken.
409user_limit_reachedThe instance is at its account ceiling.
409username_heldA former owner still holds that name, and it has not lapsed.
409username_reservedThat name is held back by the service.
409username_retiredIt belonged to a deleted account and is kept out of use.
409username_unavailableThat name is not available.
410link_disabledAn administrator disabled the link, or the owner's account is disabled.
410link_exhaustedAt its click limit, with no fallback set.
410link_expiredPast its expiry, with no fallback set.
410link_retiredThe link was deleted. That code will never work again.
413payload_too_largeA body above 64 KB. Send less. Nothing was saved.
422acknowledgement_requiredA destructive action needs its acknowledgement field.
422invalid_requestFailed validation. The message names the field.
429rate_limitedToo 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.
429username_cooldownA username was changed too recently to change again. The message says when. Nothing to retry before then.
500code_exhaustedNo free random code could be found. Tell the operator.
500internal_errorA fault at our end. Logged on the box in full, and opaque to you. Do not retry in a loop.
500masked_index_damagedThis account's masked-index row is missing or not the fixed width. Tell the operator. Nothing was changed, and the answer names no account.
500slug_exhaustedNo free collection slug could be found. Tell the operator.
503busyThe 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.

WhatLimitCounted per
Link creation20 / 60sper account
Group creation10 / 60sper account
Edits to links and groups120 / 300sper account
Credential writes — keys, passkeys, two-factor10 / 300sper account
QR and print renders120 / 60sper account
Sign-in, invitations, two-factor answers10 / 300sper username or token
The same, instance-wide120 / 60sone bucket
Username availability checks60 / 60sper account
Published collection renders240 / 60sper slug
The same, instance-wide3000 / 60sone bucket
Notes to the operator3 / 3600sper account
Masked-link writes — create, retarget, delete, index saves20 / 60sper account
The same, instance-wide — index saves are not counted here600 / 60sone bucket
Request body64 KBper request, declared or not
Following a short linknever limited—

Following a short link is never throttled. Throttling the product to protect against nothing is not a trade worth making.

CeilingDefaultSet by
Links per account500dashboard, and per account
Groups per account25dashboard, and per account
Links shown on one collection500environment
Live API keys per account20environment
Keys an account may ever mint100environment
Passkeys per account10environment
Accounts on the instance100dashboard
Links on the instance50,000dashboard
Masked links on the instance5,000environment
Target URL length2,048 charactersenvironment
Code length5 random, up to 64 chosenenvironment
Session lifetime14 daysenvironment
Invitation lifetime14 daysper invitation
Wait between username changes90 daysenvironment
Shortest password12 charactersenvironment

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

AccessMeans
publicNo credential at all. This is the internet-facing surface.
key or sessionAn API key or a signed-in browser. A key needs the scope named beside it.
session onlyA signed-in browser only. These change what an account is, so a key — however broadly scoped — is refused with 403 forbidden.
admin planeAnswers 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

EndpointNeedsWhat it does
GET /l/{code}publicFollow a short link in the root namespace. 302, never 301, so a link can be retargeted without fighting caches.
GET /l/{namespace}/{code}publicFollow a short link in an account's namespace. The normal shape. namespace is the owner's username.
GET /x/{code}/{key}publicFollow 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}publicFollow 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}publicA 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}/sheetpublicThe same collection, laid out for a printer. A code beside each link, capped so a sheet stays a sheet.
GET /embed/{slug}publicThe 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}publicA published collection, as JSON. Access-Control-Allow-Origin: *. Click counts are never included.
GET /docspublicThis documentation.
GET /docs/browserspublicWhich browser engines this has been looked at in, which build, and when.
GET /docs/{slug}publicOne documentation page. Public HTML. No credential, on any of them.
GET /healthzpublicLiveness and version. {"status":"ok","version":"…"}
GET /publicThe sign-in page, or your own dashboard once signed in.
GET /print/l/{code}links:readOne 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.

ConditionResult
No such code404
The owner's account is disabled410 link_disabled
Moderated by an admin410 link_disabled — never follows a fallback
active_from is in the future404 — an unreleased link must not leak that it exists
Past expires_at302 to fallback_url, else 410 link_expired
click_count has reached max_clicks302 to fallback_url, else 410 link_exhausted
max_clicks is set and the click cannot be recorded503 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
Otherwise302 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.
  • truncated is true when the collection holds more than limit links and only the first limit came back. A client paginating on count alone 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.
EndpointNeedsWhat it does
GET /print/l/{code}links:readOne 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}/duplicatelinks:writeCopy a link under a new code, in the same orbit. same orbit, target, title, window and cap
GET /api/linkslinks:readList your links, with click counts. this account's links; ?group_id= narrows to one orbit
POST /api/linkslinks:writeShorten a URL. {"url":"https://…","title":"optional","code":"optional"}
PATCH /api/links/{code}links:writeChange a link you own. {"target_url":"https://…"} and the other editable fields
DELETE /api/links/{code}links:writeDelete a link, permanently. permanent — see Care, below
GET /api/links/{code}/whenlinks:readWhen 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}/channelslinks:writeRegister 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}/qrlinks:readA QR for one of your links, as SVG. Black on white, always — a theme-aware QR is a QR that fails half the time.
FieldTypeNotes
urlstring, requiredhttp/https only, at most 2,048 characters, not on the denylist
codestring1–64 of A-Za-z0-9_-. Omit for a random 5-character one
namespacestringOmit for your own. "", "root" or "-" asks for the bare root space, which is admin-only by default
titlestringA label for your own list; never shown to a visitor
group_idintMust be a group you own
active_fromISO 8601Not live until then
expires_atISO 8601Dead after then
max_clicksint ≥ 1Burn 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_urlstringWhere an expired or used-up link goes instead of a dead end
keep_trackingboolDefault 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.

  • PATCH edits target_url, title, group_id, is_favorite, active_from, expires_at, max_clicks, fallback_url and keep_tracking. A resubmitted URL that matches what is already stored is left alone rather than re-scrubbed — an unedited field is not a retarget, so keep_tracking only 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.
  • DELETE removes 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 svg and print_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.
EndpointNeedsWhat it does
POST /api/maskedlinks:writeCreate 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:writeReplace 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:writeDelete 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-indexlinks:readRead 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-indexlinks:writeReplace 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_hash is sha256(manage_token) hex, computed once by the client at creation. Only the client holds manage_token itself; 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_token alone — 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.
  • PUT and DELETE both answer the identical 404 — 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

EndpointNeedsWhat it does
POST /api/groups/{group_id}/forkgroups:writeA 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/groupsgroups:readList your groups. list groups
POST /api/groupsgroups:writeCreate a group. {"name":"Reading"}
PATCH /api/groups/{group_id}groups:writeRename, publish or unpublish a group. Publishing answers with the slug the collection now lives at.
PUT /api/groups/{group_id}/ordergroups:writeArrange 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:writeDelete 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 carries public_url and embed_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/me carries group_quota so a client can show the allowance before it is reached.

Keys

EndpointNeedsWhat it does
GET /api/keyskeys:readList this account's API keys — never their secrets. list this account's keys (never their secrets)
POST /api/keyskeys:writeMint an API key. Session-only: a key that could mint a key could widen itself.
GET /api/keys/selfkey or sessionWhat 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:writeRename a key. A label, not a capability.
DELETE /api/keys/{key_id}keys:writeRevoke 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:read arrives 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": true adds agent_brief to 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 a 429 or 409 is 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/self answers a different question from GET /api/keys and 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 a 400 pointing at GET /api/keys, because a session is not a key.
  • PATCH takes 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

EndpointNeedsWhat it does
GET /api/docsdocs:readEvery documentation page: slug, title, summary. the manual, as text
GET /api/docs/{slug}docs:readOne 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

EndpointNeedsWhat it does
POST /api/loginpublicSign in with a username and password. May answer with a second-factor challenge rather than a session.
POST /api/logoutpublicEnd this session.
GET /api/mekey or sessionWho this credential is, and what it may do.
GET /api/signup/statepublicWhether accounts can be made here, and on what terms.
POST /api/signuppublicRedeem an invitation and create the account.
GET /api/username/availablekey or sessionIs a username free.
POST /api/usernamesession onlyChange your username. Your old namespace is held rather than released — see Accounts.
POST /api/facets/variantsession onlyChoose 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}
  • role is null until the sign-in is actually finished. A password alone should not reveal whether it opens an admin account. Read the role from GET /api/me once the second factor has passed.
  • A session carrying mfa_required or mfa_enrolment_required authenticates nothing: every endpoint but the two-factor ones answers 401 until the second factor is presented, and it expires in ten minutes rather than the usual fortnight.
  • GET /api/me carries 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
Movesthe account name, and every link whose namespace was the old name: its code, its namespace, and its click rows
Retiresevery old /l/<old-name>/<code> as a tombstone, and the old name itself
Leaves aloneroot-namespace links, collection slugs, API keys, passkeys, the pass phrase, the authenticator, and every historical audit row
  • acknowledge: true is 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 WHERE clause 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_collision means 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

EndpointNeedsWhat it does
GET /api/mfa/statepublicHow far an in-flight sign-in has got.
POST /api/mfa/verifypublicAnswer a six-digit challenge.
POST /api/mfa/recoverypublicAnswer with a backup code instead.
POST /api/mfa/enrol/beginpublicStart enrolling an authenticator app. Reachable from a first sign-in as well as from a session.
POST /api/mfa/enrol/completepublicFinish enrolling, and receive ten backup codes.
GET /api/mfasession onlyYour enrollment state and settings.
POST /api/mfa/settingssession onlyAsk for a code when signing in with a passkey too.
POST /api/mfa/recovery/regeneratesession onlyTen new backup codes; the old ten stop working.
POST /api/passkeys/login/beginpublicStart a passkey sign-in.
POST /api/passkeys/login/completepublicFinish a passkey sign-in.
GET /api/passkeyssession onlyList the passkeys on this account.
POST /api/passkeys/register/beginsession onlyStart adding a passkey.
POST /api/passkeys/register/completesession onlyFinish adding a passkey.
PATCH /api/passkeys/{cred_id}session onlyRename a passkey.
DELETE /api/passkeys/{cred_id}session onlyRemove 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

EndpointNeedsWhat it does
GET /api/requestskey or sessionThe code releases you have asked for, and their answers.
POST /api/requestskey or sessionAsk an admin to release a retired code.
POST /api/feedbackkey or sessionSend the operator a note. Stored with no attribution at all — see Privacy.
POST /api/browser-reportskey or sessionVolunteer 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.

EndpointNeedsWhat it does
GET /api/admin/usersusers:readList accounts.
POST /api/admin/usersusers:writeCreate an account.
PATCH /api/admin/users/{user_id}users:writeChange a role or a quota, or disable an account.
DELETE /api/admin/users/{user_id}users:writeDelete an account. Refuses on the last admin, and on yourself.
GET /api/admin/invitesusers:readOutstanding and spent invitations.
POST /api/admin/invitesusers:writeIssue an invitation.
DELETE /api/admin/invites/{invite_id}users:writeRevoke an unused invitation.
GET /api/admin/settingsusers:readThe deployment scope: ceilings and switches.
PATCH /api/admin/settingsusers:writeChange the deployment scope.
GET /api/admin/metricsusers:readWhat this box is doing, and what it costs.
GET /api/admin/auditusers:readThe audit log.
POST /api/admin/audit/purgeusers:writePurge audit rows older than a cutoff. The fact of the purge is itself recorded.
GET /api/admin/tombstonesusers:readRetired codes.
DELETE /api/admin/tombstones/{code}users:writeRelease a retired code for reuse.
POST /api/admin/moderate/{code}users:writeDisable or re-enable one link. A moderated link never follows a fallback.
POST /api/admin/moderate-masked/{code}users:writeDisable 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/notesusers:readEvery account's notes to the developer, with their author.
PATCH /api/admin/notes/{note_id}users:writeClose anybody's note with an outcome they will see.
GET /api/admin/requestsusers:readCode-release requests from account holders.
PATCH /api/admin/requests/{request_id}users:writeAnswer a code-release request.
GET /api/admin/feedbackusers:readNotes sent by account holders.
PATCH /api/admin/feedback/{feedback_id}users:writeMark a note read.
DELETE /api/admin/feedback/{feedback_id}users:writeDelete a note.
GET /api/admin/browsersusers:readVolunteered browser reports, counted.
PATCH /api/admin/browsers/{slug}users:writeSet an engine's support status.
DELETE /api/admin/browsers/{slug}/reportsusers:writeClear 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 username field 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 — summit is 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.code values are stable. Branch on those, never on error.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.

Back to top

The same page as text, for a program: GET https://geodzk.com/api/docs/api — needs a key holding docs:read.

Sign in · geodzk.com