API

The v1 API

One credential, a versioned REST surface, and a machine-readable contract generated from the code that enforces it.

Who can use it today

One administrator, holding one token. There is no signup and no account system, so there is no self-serve way to obtain a credential yet — the surface is documented here because it is real and running, not because you can sign up for it. Links you create through the public form stay anonymous and unowned: nothing in the API can list, read, edit or report on them.

Authentication

API clients send the token in an Authorization header, as a Bearer credential. Every failure — no token, a malformed one, an unknown one, a revoked one, an expired one — answers with exactly the same 401 and the same body, because an endpoint that tells them apart tells an attacker which guess was once real.

curl https://gecis.in/api/v1/links \
  -H "Authorization: Bearer gcs_live_…"

The admin panel does not keep the token. It posts it once to the session endpoint; the server verifies it and replies with an httpOnly, Secure, SameSite=Lax cookie that no script on the page can read. The session slides forward while it is being used, stops at a hard ceiling whatever it has been doing, and logging out revokes it in the database rather than asking the browser to forget it.

curl -X POST https://gecis.in/api/v1/auth/session \
  -H "Content-Type: application/json" \
  -d '{"token":"gcs_live_…"}' -c cookies.txt

A token carries 256 bits of entropy behind a fixed, self-identifying prefix, so one that leaks into a commit or a screenshot is recognisable to a secret scanner that has never heard of us. Only a SHA-256 digest of the whole string is stored: the value is shown once, when it is created, and no query anywhere can produce it again.

entropy
256 bit
prefix
gcs_live_…
stored as
SHA-256
session idle
30 min
session ceiling
12 h

Limits

Per token, per hour, across the whole surface — with a tighter ceiling on link creation. Token verification is limited separately and much harder, by client address, and that lockout lives in the database rather than in memory so that restarting the process does not hand the attempts back. Every response carries the remaining allowance in its headers, and a refusal carries Retry-After.

requests / token / hour
1000
link creates / token / hour
300
session attempts / address / hour
20
lockout
10 / 15 min

Endpoints

MethodPathWhat it does
POST/api/v1/auth/sessionExchange a token for a panel session cookie
GET/api/v1/auth/sessionDescribe the credential this request used
DELETE/api/v1/auth/sessionLog out, and revoke the session server-side
GET/api/v1/linksList your links, paginated, filtered and sorted
POST/api/v1/linksCreate one link, with an optional custom slug
POST/api/v1/links/bulkCreate many links at once, each with its own outcome
GET/api/v1/links/{slug}Read one link
PATCH/api/v1/links/{slug}Change a link’s destination or its active flag
DELETE/api/v1/links/{slug}Delete a link, and the clicks recorded against it
GET/api/v1/links/{slug}/analyticsAggregates for one link
GET/api/v1/analyticsAggregates across every link you own
GET/api/v1/links/{slug}/qrA QR code for a link, as PNG or SVG
GET/api/v1/tokensList tokens, with their state and never their value
POST/api/v1/tokensMint a token, shown once and never again
DELETE/api/v1/tokens/{id}Revoke a token, and every session it opened
GET/api/v1/openapi.jsonThis surface, as an OpenAPI document

Errors

One envelope everywhere: a stable code a client can branch on, and an English sentence for whoever is reading the log. The sentences are deliberately not translated — they are part of the contract, not page copy — and validation failures add a list of the offending fields. No error body ever contains a credential, a hash or an address.

CodeStatusMessage
invalid_body400The request body is not valid JSON.
validation_failed400One or more fields are invalid.
invalid_query400One or more query parameters are invalid.
invalid_target400The destination URL was refused.
invalid_slug400The requested slug is not a valid slug.
unauthorized401A valid API credential is required.
not_found404No such resource.
method_not_allowed405That method is not supported here.
slug_taken409That slug is already in use.
slug_reserved409That slug is reserved and cannot be allocated.
slug_collision409Could not allocate a free slug; retry.
payload_too_large413The request body is too large.
unsupported_media_type415Send application/json.
rate_limited429Too many requests. Retry after the given delay.
server_error500The request could not be completed.
unavailable503The database is unavailable.

What the API will not return

No endpoint returns a visitor IP address, at any level of aggregation, and there is no endpoint that returns a click row at all — everything is a count. This is a published claim on the comparison table, where it is a point of contrast against vendors whose click objects carry the address, so it is enforced in the code rather than left as an intention: the dimension list has no entry for it, and a test fails if any response grows one.

Captured, but never a bucket you can group by

  • clicked_atServed as the time series instead
  • ipIdentity. Never exposed, at any aggregation
  • ip_hashIdentity. Never exposed, at any aggregation
  • visitor_keyCounted, never returned or grouped on
  • latitudeCity centroid; the city is the honest bucket
  • longitudeCity centroid; the city is the honest bucket

The machine-readable contract

The OpenAPI document is generated at request time from the very schemas the handlers validate with, so a field renamed in the code is renamed in the document. Nothing in it is written by hand, and a route with no documented operation — or an operation with no route — fails the test suite.

Open the OpenAPI document