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.

Instructions for an AI agent

Written for a reader holding a key, part-way through a task, who will not come back for the rest of the manual.

If you are holding an API key for this service, this page is the manual. It is one page on purpose. Read it before your first call.

geodzk is a link shortener. It turns a long URL into a short one under a person's namespace, https://geodzk.com/l/name/code, so the link can go on paper — a poster, a label, a card. It is one small container on a machine that has other work to do.

Three words name one thing, and you will meet all three. The people using the dashboard call a set of links an orbit; the API calls it a group (/api/groups); once published, its public page is a collection (/c/slug). When somebody asks you for a collection, make a group. An agent handed only a key worked this out by guessing; you do not have to.

The one thing to get right

You are acting as a named person on a domain they own. Every call you make is logged under their name and is their responsibility, and every link you create carries their address. Shorten what you were asked to shorten and nothing else.

The key

  • Send it as the header X-API-Key. Nothing else authenticates.
  • Send it only to https://geodzk.com. Nothing else accepts it, and anything else asking for it is not entitled to it.
  • Keep it in an environment variable or a secrets store. Not in a file you commit, a log line, a transcript, an issue, a code comment, or a message to another service — those are where a credential like this actually leaks.
  • You cannot create a replacement. Keys are only ever minted by a person in a browser, so if yours stops working, say so and stop.
  • GET /api/keys/self tells you what your key is called, what it may do, and when it expires. Ask that rather than guessing.
  • If your request cannot leave the machine you are running on at all — a connection refused, a timeout, a proxy error, rather than a 401 or a 403 — that is egress, not the key. Many agents run in a sandbox with no outbound network, or with an allowlist. Say so plainly and ask for https://geodzk.com to be allowed. Do not retry it in a loop, and do not conclude the credential is bad: a key that is refused by this service gets a JSON error back, not silence.

What you may call

Your key carries scopes, and anything outside them returns 403. That is the answer, not a hint to try a different route.

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
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/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.
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.
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.
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.
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.
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.
GET /api/notesnotes:readYour notes to the developer, open first. the owner's notes to you; ?state=open for the open ones
POST /api/notesnotes:writeWrite one down. {"body":"…","page":"/optional/path/it/was/written/from"}. Attributed and audited, unlike feedback. Twenty an hour; a ceiling on how many stay open.
PATCH /api/notes/{note_id}notes:writeClose a note with an outcome, or reopen it. {"state":"done","outcome":"what happened, one line"}
DELETE /api/notes/{note_id}notes:writeDelete a note of yours. Permanent.
curl -s -X POST https://geodzk.com/api/links \
  -H "X-API-Key: $GEODZK_KEY" -H 'Content-Type: application/json' \
  -d '{"url":"https://example.com"}'
# the reply's short_url is the link to hand back

What you can do here that your owner would not do by hand

Every one of these is reachable through the interface, one form at a time, and in practice nobody does them. This is the part of the service that only really exists for something holding a key — so if you are looking for the useful thing to offer, it is on this list rather than in shortening one URL.

  • Retarget a whole printed run in one pass. The QR on a label encodes the short URL, not the destination, so PATCH across every code in a batch moves forty labels without reprinting one. This is the capability the service is built around; by hand it is forty edits, and the fortieth is where the mistake is.
  • Take a list and hand back something a printer can accept. Create the links, POST /api/groups, PATCH that group to "visibility":"public", and the reply carries the collection's URL — whose /sheet is the whole list laid out for paper with a code beside each entry. Four calls, however long the list.
  • Fill in the fields that make a printed thing survive. expires_at with a fallback_url means an invitation card that goes to the details before the day and the photographs after it, with nothing reprinted. max_clicks makes a single-use handout. active_from lets a run go out before the thing it points at exists. These are the fields a person never fills in for thirty rows.
  • Answer a question the interface does not have a screen for. Which of these codes has never been followed; which is still being followed a month after the event; which label out-performed which poster — all of it is arithmetic over GET /api/links, and none of it is a page here.
  • Produce the QR yourself. GET /api/links/{code}/qr answers with inline SVG, black on white, ready to drop into whatever you are assembling. For something to print directly, hand your owner /print/l/<code>?size=label|card|poster|cut — that one is a page for them to open, since it is scoped to their account.
  • Explain the service accurately instead of approximately. You hold GET /api/docs, which is this whole manual as text. If they ask what is recorded, what a retired code means, or whether a link can be moved after printing, the answer is a page away and it is the same answer the service enforces.

The one to lead with is the first: somebody who has never had a link they could move after printing it usually does not think to ask for it. What this unlocks is the same argument written for them, including where this service is worse than the alternatives — which is worth reading before you recommend it for anything.

Two cautions specific to acting in bulk. A code is permanent, so agree the naming before creating a run rather than after — and retarget rather than delete, always: deleting to tidy up retires codes that may already be on paper. And creation is rate-limited, so a long run is paced, not refused: read the number of seconds in a 429 and wait it out rather than retrying.

Failure, and the one thing that causes real harm

  • 429 means you are going too fast. Wait the number of seconds in the message. Do not loop.
  • 409 means a real conflict — full, taken, or retired. It will still be a conflict on the next attempt. Do not loop.
  • 403 means stop and report. Neither a wider route nor a retry exists.
  • A retry loop against any of those is the single most harmful thing you can do here: this service runs on one small machine, and the limits exist because a stranger making it expensive is treated as a security problem.

Deleting is permanent

Deleting a link retires its code for good: that address can never point at anything again, for this account or any other, because the old one may be printed on something somebody still has. Never delete a link to tidy up. If a link is wrong, retarget it.

What this service records

  • Per link: how many times it was followed, and when it was last followed. Nothing else.
  • No IP addresses. No user agents. No referrers.
  • No email addresses anywhere in the service — there is no column for one.
  • One exception, and it is volunteered rather than observed: an account holder may type a browser engine and version into a form, and that pair is counted. Nothing detects it, and no report records who sent it.

Do not offer the owner analytics this service cannot produce. If they ask for per-visitor data, the honest answer is that it does not exist here.

Errors

Always {"error":{"code":"…","message":"…"}}. The message is a real sentence written for a reader: act on it rather than guessing from the status code. The full table is on The API.

If this page and the service disagree

The service is right. This page is generated from the same code that enforces the rules, so a disagreement is a bug worth reporting to the owner — say what you called, what you expected, and what came back.

If you are reading this yourself and not through an agent: that is the intended way round. This page is the whole boundary — there is no second document written for programs, no field an agent can set that a person cannot, and nothing on it that would be awkward to read out loud.

Back to top

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

Sign in · geodzk.com