v1 · stable
Turn a long URL into three characters, from your own code.
Every endpoint the worker answers, documented as it actually behaves. JSON in, JSON out, and a session cookie for anything that touches an account. There is no separate API key to manage — and no account needed at all to create your first link.
01 Introduction
Waitin Zone shortens a URL to a three-to-six character code served from Cloudflare's edge. The same HTTP API the site's own pages use is the one documented here — there is no second, private interface, so anything the dashboard can do you can do.
Base URL
https://w8.nz
Conventions
- Request bodies are JSON. Send
Content-Type: application/json. - Every response is JSON with a boolean
successfield. - A failure carries a human-readable
errorstring. It is written to be shown to a person as-is. - Times are UTC. Durations are seconds unless the field name says otherwise.
- Short codes are case sensitive:
a4fandA4Fare different links.
There are no API keys. Authentication is the same session cookie the website uses. That is a deliberate limitation, not an oversight — it keeps one credential to revoke instead of two, and the endpoint that matters most, creating a link, needs no credential at all.
02 Quickstart
The shortest useful request. No account, no headers beyond the content type, six-character code back.
# Create a link
curl -X POST https://w8.nz/api/shorten \
-H 'Content-Type: application/json' \
-d '{"targetUrl":"https://example.com/a/very/long/path"}'
{
"success": true,
"code": "q7Bxa2",
"shortUrl": "https://w8.nz/q7Bxa2",
"targetUrl": "https://example.com/a/very/long/path",
"length": 6,
"remaining": 49,
"limit": 50
}
From JavaScript
const res = await fetch('https://w8.nz/api/shorten', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetUrl: 'https://example.com/' })
});
const data = await res.json();
if (!data.success) throw new Error(data.error);
console.log(data.shortUrl);
Calling this from a browser? There is no Access-Control-Allow-Origin header on these endpoints, so a request from another site's front-end will be refused by the browser. Call it from your server.
03 Authentication
Signing in returns a w8_session cookie: HttpOnly, SameSite=Lax, Secure over HTTPS. Send it back on any request that needs an account. Anything not listed as requiring a session works without one.
Exchanges credentials for a session cookie.
| Field | Notes | |
|---|---|---|
username | Required | Username or email address. |
password | Required | |
twoFactorCode | Conditional | Six-digit TOTP, or one backup code. Required only once the first attempt answers requires2FA. |
{ "success": false, "requires2FA": true } // send the code and repeat
Who the current cookie belongs to. Returns { "success": true, "user": null } when there is no session, rather than a 401, so it is safe to call on every page load.
{
"success": true,
"user": {
"username": "sami",
"email": "s***@example.com",
"plan": "pro",
"effectivePlan": "pro",
"twoFactorEnabled": true
}
}
plan versus effectivePlan. plan is what was bought; effectivePlan is what is currently granted. A lapsed subscription leaves the first as pro and drops the second to free. Always gate features on effectivePlan.
Clears the cookie. Always answers 200, session or not.
The rest of the account surface
| Endpoint | Session | Purpose |
|---|---|---|
POST /api/auth/register | No | Creates an unverified account and emails a six-digit code. |
POST /api/auth/verify-email | No | Confirms that code and signs the account in. |
POST /api/auth/resend-code | No | Sends another. Refused within 60 seconds of the last one. |
POST /api/auth/forgot-password | No | Emails a reset link. Answers the same whether or not the address exists. |
POST /api/auth/reset-password | No | Consumes the token and signs every device out. |
GET /api/auth/check-reset | No | Tests a reset token before showing a password field for it. |
POST /api/auth/change-email | Yes | Sends a confirmation to the new address. |
POST /api/auth/confirm-email-change | Yes | Completes the change. |
POST /api/auth/2fa/setup | Yes | Returns a TOTP secret and its otpauth URI. No backup codes yet. |
POST /api/auth/2fa/verify | Yes | Confirms one code, switches 2FA on, and returns the ten backup codes once. |
POST /api/auth/2fa/disable | Yes | Requires a valid TOTP or backup code. |
Setup deliberately withholds the backup codes until verify succeeds. Handing them out at setup would mean anyone who reached that endpoint walked away with ten permanent bypasses whether or not they ever proved they could generate a code.
04 Create a link
Screens the destination, picks a free code and stores the link. This is the only endpoint most integrations need.
| Field | Notes | |
|---|---|---|
targetUrl | Required | http or https, at most 2,048 characters. A w8.nz URL is refused. |
customLength | Optional | 3, 4, 5 or 6. Defaults to 5 with a session, 6 without. Anything else is ignored rather than rejected. |
turnstileToken | Conditional | Required when the deployment has Turnstile configured. Browser callers get this from the widget. |
Success
{
"success": true,
"code": "a4f",
"shortUrl": "https://w8.nz/a4f",
"targetUrl": "https://example.com/...",
"length": 3,
"remaining": 99, // null when the plan is unlimited
"limit": 100 // null when the plan is unlimited
}
Refusals worth handling
| Status | Body carries | Meaning |
|---|---|---|
400 | error | Not a URL, wrong scheme, over 2,048 characters, already a w8.nz link, or the human check failed. |
401 | needsAccount | That length needs an account. Free gets five characters. |
403 | needsPlan | That length needs a paid plan. The value is the lowest plan that has it. |
403 | blocked | Google Safe Browsing matched the destination. The code is never created. |
429 | Retry-After | Daily quota for that length is spent. |
A lapsed subscription reads as a plan error, not an auth error. When a Pro account falls back to free, a request for a three-character code answers 403 with a message naming the lapsed plan. Show it as-is; it already explains what renewing will restore.
05 Following a link
Answers 302 Found with the destination in Location, and records the click.
$ curl -sI https://w8.nz/a4f | head -3
HTTP/2 302
location: https://example.com/...
cache-control: no-store
302, never 301. A permanent redirect is cached by the browser forever: the click counter stops moving after the first visit, and a link removed for abuse keeps working for everyone who already followed it. The round trip is the price of keeping control of the link, and no-store is what makes that price actually get paid.
HEAD is answered exactly like GET with the body dropped, which is what Slack, Discord and WhatsApp send before they unfurl a link.
An unknown code returns the 404 page. A code whose owner's subscription has lapsed past its grace period returns a paused page instead of redirecting — the link is not gone, and renewing restores it.
What a click records
Country, device class, browser and referring host — all of it read from what Cloudflare already attaches to the request. No cookie is set on the person clicking and no identifier follows them anywhere. The last hundred clicks per link are kept.
06 Analytics
Everything recorded for one of your links. Fields above your plan come back as null rather than being omitted, so the shape never changes.
{
"success": true,
"code": "a4f",
"targetUrl": "https://example.com/...",
"clicks": 1284,
"createdAt": "2026-08-01T09:12:44.000Z",
"daily": [ ... ], // 14 days of totals
"devices": { ... }, // null on free
"referrers": { ... }, // null on free
"countries": { ... }, // pro only
"browsers": { ... }, // pro only
"recentClicks": [ ... ] // pro only, last 20
}
Requesting a link you do not own answers 403, not 404 — the code demonstrably exists, so pretending otherwise would be theatre.
07 List and delete
Every link you own with its click total, plus today's quota for each length.
Removes the link. The code returns to the pool and may be handed to somebody else later, so treat deletion as permanent.
curl -X DELETE https://w8.nz/api/link/delete/a4f \
-H 'Cookie: w8_session=…'
Reports a link. Requires the code and a reason. Reported links are retired by hand and their codes are never reissued.
08 Errors
Every failure has the same shape. There are no numeric error codes to look up — the string is the contract, and it is written to be displayed.
{ "success": false, "error": "That URL is longer than 2048 characters." }
success is still worth checking.Retry-After carries the seconds.09 Limits & plans
Quotas are per day and per length, and they reset on a rolling 24-hour window rather than at midnight. A dash means that length is not available on the plan at all.
| Length | No account | Free | Plus | Pro |
|---|---|---|---|---|
6 characters | 50 | 100 | 1,000 | 5,000 |
5 characters | — | 100 | 1,000 | Unlimited |
4 characters | — | — | 100 | 1,000 |
3 characters | — | — | — | 100 |
Guest quota is counted per network address; account quota is counted per account. Sign-in attempts are limited separately, per account and per network.
Analytics depth follows the plan too: free sees totals, Plus adds devices and referrers, Pro adds countries, browsers and the last twenty individual clicks.
10 Security
Destinations
Every URL is checked against Google Safe Browsing — malware, social engineering, unwanted software, harmful applications — before a code is generated. A link that fails never exists, so there is nothing to disable later.
Accounts
- Passwords: PBKDF2-SHA256 at 300,000 iterations with a per-account salt. The cost is stored on the record so it can be raised later without locking anyone out.
- Two-factor: RFC 6238 TOTP over a 30-second period, accepting one step of drift either side.
- Backup codes: ten, single use, stored only as hashes.
- Changing a password invalidates every existing session.
The session cookie is the credential. It is HttpOnly, so page scripts cannot read it, and SameSite=Lax, so another site cannot make an authenticated request on your behalf. If you store it server-side to call this API, treat it exactly as you would a password.
11 Questions
Can I choose my own code?
Not yet. You choose the length; the code itself is generated and checked for collisions. Vanity codes would need a reservation and moderation model, and shipping them without one is how a shortener ends up hosting somebody else's brand name.
Do links expire?
No. A link lives until you delete it, it is reported and retired, or the owning subscription lapses for more than fourteen days — seven days working, seven days paused, then removed.
Is there a bulk endpoint?
No. Call /api/shorten in a loop and respect the quota; each call is one Safe Browsing round trip and roughly 300 ms.
Are codes case sensitive?
Yes. The alphabet is base62, so a4f and A4F are two different links. Do not lowercase a code before looking it up.
Can I see who clicked?
No, and neither can we. Country, device class, browser and referring host are all that is recorded, none of it joined to a person.