Overview
API v1 has four endpoints. Every request goes to the base URL below and carries your API token.
https://api.gjw.cx/api/v1
| Method | Path | What it does |
|---|---|---|
| POST | /shorten | Create a short link, or get back your existing link for the same URL. |
| GET | /links | List the links in your account, newest first. |
| GET | /link/{id}/stats | Get one link with its click statistics. |
| DELETE | /link/{id} | Move a link to Trash. |
Conventions
- JSON over HTTPS. Responses are JSON.
POST /shortentakes a JSON object and needs the headerContent-Type: application/json; a form-encoded or plain-text body returns400 invalid_json. Request bodies are limited to 16 KB. - Exact paths. Paths are case-sensitive and have no trailing slash. The
/apipart belongs to the base URL, sohttps://api.gjw.cx/v1/...does not work. - Key order. Do not rely on the order of keys in a JSON response.
- Timestamps. A link's
created_atis"YYYY-MM-DD HH:MM:SS"in server time (UTC+7) with no offset. Links made with bulk shortening or the QR code tool of the web app have only a date,"YYYY-MM-DD", so accept both forms.first_clickandlast_clickare ISO 8601 with an offset, for example"2026-10-08T18:32:05.261000+07:00". - Server-to-server. Call the API from your server or script: curl, Node.js, Python, PHP or Google Apps Script. Browsers block API responses on other websites (CORS), and an API token must never be placed in front-end code.
- Every plan. All plans include the API. The limits differ by plan; see Limits by plan.
Authentication
Send your API token in the Authorization header of every request:
Authorization: Bearer YOUR_API_TOKEN
Get a token
- Sign in and open app.gjw.cx/api-token.
- Click Generate token. The full token is shown only once, right after it is created, so copy it then. Later the page shows only its first characters.
- Store it where your code can read it, for example in an environment variable or in the script properties of Google Apps Script. Never commit it to version control.
A token is gjw_live_ followed by 32 letters, digits, - or _.
Rules
- Write
Bearerexactly like this, with a capital B and one space before the token.bearer,Token, a bare token, anX-API-Keyheader, a query parameter or a cookie are not accepted and return401 missing_token. - Your account has one active token. Regenerate creates a new token and revokes the previous one immediately: requests with the old token return
401 invalid_token. You can regenerate at most once every 30 seconds. - Tokens do not expire. A token stops working only when you regenerate it or when gjw.cx revokes it.
- Limits belong to your account, not to the token, so regenerating does not reset them.
- If your account is blocked, every request returns
403 account_blocked. - The login session of the gjw.cx website is not an API token. Sending it returns
401 invalid_token.
Getting 401 "Invalid or expired token"? That message, without a code field, comes from a website endpoint such as /api/shorten-url, not from API v1. Send your request to https://api.gjw.cx/api/v1/shorten instead. See Do not use the web endpoints.
Quick start
1. Get your API token
Create it on app.gjw.cx/api-token and copy it.
2. Create a short link
Replace YOUR_API_TOKEN with your token and run:
curl -X POST https://api.gjw.cx/api/v1/shorten \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/a/very/long/page"}'
3. Read the response
A new link returns 201 Created:
{
"clicks": 0,
"created_at": "2026-10-08 18:30:20",
"description": "",
"existing": false,
"id": "abc123",
"is_custom": false,
"long_url": "https://example.com/a/very/long/page",
"short_url": "https://gjw.cx/abc123",
"title": ""
}
short_url is the complete short link: use it as it is. id is the short code that the other endpoints take in their path. Send the same request again and you get the same link back, with 200 and "existing": true.
Endpoints
All paths below are relative to https://api.gjw.cx/api/v1.
Create a short link
Creates a short link. If you already have a link without a custom code for exactly the same URL, the API returns that link instead, with 200 and "existing": true. Getting an existing link back does not use any link limit.
Request body
| Field | Type | Description |
|---|---|---|
url | string, required | The destination. It must start with http:// or https://, include a host name, contain no spaces and be at most 2,048 characters. Spaces at the start and end are removed. long_url is accepted as an alias. |
custom | string, optional | Your own short code, such as SpringLaunch. See Custom links. Leave it out, or send null or "", to get a generated code. It is used exactly as sent: spaces are not trimmed, so a code with a space anywhere returns 400 invalid_slug. custom_short_url is accepted as an alias. |
description | string, optional | A note for yourself, up to 500 characters. |
Fields not listed here are ignored without an error, so check the spelling: custom_alias or title, for example, have no effect.
Example
curl -X POST https://api.gjw.cx/api/v1/shorten \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/spring-sale",
"custom": "SpringLaunch",
"description": "Spring newsletter"}'
{
"clicks": 0,
"created_at": "2026-10-08 18:31:40",
"description": "Spring newsletter",
"existing": false,
"id": "SpringLaunch",
"is_custom": true,
"long_url": "https://example.com/spring-sale",
"short_url": "https://gjw.cx/SpringLaunch",
"title": ""
}
Response
| Field | Type | Description |
|---|---|---|
id | string | The short code exactly as it was created, used in /link/{id} paths. Letter case is ignored when you use it in a path. |
short_url | string | The full short link: https://gjw.cx/ followed by id. |
long_url | string | The destination URL. |
description | string | The description, or "". |
title | string | Always "" in this response. GET /links and the statistics endpoint return the stored title, which is "Untitled" for links created through the API. |
created_at | string | "YYYY-MM-DD HH:MM:SS" in server time (UTC+7), with no offset, or only "YYYY-MM-DD" for links made with bulk shortening or the QR code tool. |
is_custom | boolean | true when the link has a custom code. |
clicks | integer | Always 0 in this response. Use Get link statistics for click counts. |
existing | boolean | true when your existing link was returned (status 200); false for a new link (status 201). |
When existing is true, description repeats what you sent in this request, not the stored description, and the stored link is not changed.
{
"clicks": 0,
"created_at": "2026-10-08 18:30:20",
"description": "",
"existing": true,
"id": "abc123",
"is_custom": false,
"long_url": "https://example.com/a/very/long/page",
"short_url": "https://gjw.cx/abc123",
"title": ""
}
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_json | The body is missing or is not a JSON object sent with Content-Type: application/json. |
| 400 | invalid_url | url is missing or is not a valid http or https URL. |
| 400 | invalid_slug | custom is not a string, has a character other than A-Z, a-z, 0-9, - and _, is longer than 100 characters, starts or ends with - or _, or has - or _ next to each other. The message names the exact reason. |
| 400 | slug_too_short | custom is shorter than 6 characters. |
| 400 | reserved_slug | custom is a reserved word. See Custom links. |
| 400 | invalid_description | description is not a string or is longer than 500 characters. |
| 403 | domain_blocked | The destination domain has been blocked by gjw.cx. |
| 403 | daily_limit_reached | You reached New links per day. See Limits by plan. |
| 403 | link_limit_reached | You reached Links per account. |
| 403 | custom_limit_reached | You reached Custom links. |
| 404 | user_not_found | The account no longer exists. |
| 409 | slug_taken | The custom code is already in use, by you or by another account, in any letter case. |
| 503 | slug_unavailable | A unique short code could not be generated, or another request was creating the same custom code at that moment. Send the request again. |
custom is checked in this order: reserved word (reserved_slug), shorter than 6 characters (slug_too_short), other characters, longer than 100 characters, - or _ at the start or end, - or _ next to each other (all invalid_slug), already in use in any letter case (slug_taken). Plan limits are checked after these.
The errors that every endpoint can return are listed under Errors.
List your links
Lists the links currently in your account, newest first by created_at (see Pagination). Links in Trash are not included.
Query parameters
| Parameter | Type | Description |
|---|---|---|
limit | integer, optional | Links per page, from 1 to 100. The default is 50. |
cursor | string, optional | The next_cursor value from the previous page, unchanged. See Pagination. |
Example
curl "https://api.gjw.cx/api/v1/links?limit=2" \
-H "Authorization: Bearer YOUR_API_TOKEN"
{
"links": [
{
"clicks": 0,
"created_at": "2026-10-08 18:31:40",
"description": "Spring newsletter",
"id": "SpringLaunch",
"is_custom": true,
"long_url": "https://example.com/spring-sale",
"short_url": "https://gjw.cx/SpringLaunch",
"title": "Untitled"
},
{
"clicks": 7,
"created_at": "2026-10-08 18:30:20",
"description": "",
"id": "abc123",
"is_custom": false,
"long_url": "https://example.com/a/very/long/page",
"short_url": "https://gjw.cx/abc123",
"title": "Untitled"
}
],
"next_cursor": "WyIyMDI2LTEwLTA4IDE4OjMwOjIwIiwgImFiYzEyMyJd"
}
Each item in links has the fields described under Create a short link, without existing. Here clicks is the real total. next_cursor is null on the last page, and an account with no links gets {"links": [], "next_cursor": null}.
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_limit | limit is not a whole number from 1 to 100. |
| 400 | invalid_cursor | cursor is not a next_cursor value returned by the API. |
Get link statistics
Returns one of your links with its click statistics. {id} is the short code, for example abc123, not the full short URL. Letter case is ignored, so ABC123 finds the same link. If you send the full short URL instead, the response is the HTML 404 page described under Errors, not not_found.
Example
curl https://api.gjw.cx/api/v1/link/abc123/stats \
-H "Authorization: Bearer YOUR_API_TOKEN"
{
"clicks": 7,
"created_at": "2026-10-08 18:30:20",
"description": "",
"first_click": "2026-10-08T18:32:05.261000+07:00",
"id": "abc123",
"is_custom": false,
"last_30_days": [
{"clicks": 7, "date": "2026-10-08"}
],
"last_click": "2026-10-08T19:04:51.267000+07:00",
"long_url": "https://example.com/a/very/long/page",
"qr_scans": 1,
"short_url": "https://gjw.cx/abc123",
"title": "Untitled",
"top_countries": [
{"clicks": "3", "value": "VN"},
{"clicks": "2", "value": "US"},
{"clicks": "1", "value": "SG"},
{"clicks": "1", "value": "Unknown"}
],
"top_referrers": [
{"clicks": "3", "value": "google.com"},
{"clicks": "2", "value": "Direct"},
{"clicks": "2", "value": "news.ycombinator.com"}
]
}
Response
The link fields described under Create a short link (without existing), plus:
| Field | Type | Description |
|---|---|---|
clicks | integer | All counted clicks for this short code, QR code scans included. A code that was deleted and created again keeps its earlier clicks, so first_click can be earlier than created_at. |
qr_scans | integer | Clicks that came through the link's QR code. |
first_click, last_click | string or null | ISO 8601 with an offset, or null before the first click. |
last_30_days | array | {"date": "YYYY-MM-DD", "clicks": integer} for the last 30 days including today, as dates in your account's time zone, oldest first. Days without clicks are left out. |
top_countries | array | Up to 5 {"value", "clicks"} items for all time. value is a two-letter country code such as VN, or "Unknown". clicks is a string, for example "3". |
top_referrers | array | Up to 5 {"value", "clicks"} items for all time. value is the referring domain, such as google.com, or "Direct". clicks is a string. |
A link that has never been clicked returns 0 for clicks and qr_scans, null for first_click and last_click, and empty arrays.
What counts as a click
Visits from real browsers and apps. Requests from curl and other HTTP tools or libraries, bots, link previews, prefetching, HEAD requests and requests without a User-Agent are not counted, so opening your link with curl shows 0 clicks. Repeated clicks from the same IP address all count. Clicks are processed in the background and can take about a minute to appear.
Errors
| Status | Code | When |
|---|---|---|
| 404 | not_found | There is no link with this id in your account. A link that belongs to another account also returns 404. |
Delete a link
Moves one of your links to Trash. Letter case in {id} is ignored; the id in the response is the code as it was created.
Example
curl -X DELETE https://api.gjw.cx/api/v1/link/abc123 \
-H "Authorization: Bearer YOUR_API_TOKEN"
{
"deleted": true,
"id": "abc123",
"message": "The link was moved to trash. It can be restored within 30 days.",
"status": "moved_to_trash"
}
- The short link stops redirecting immediately. It no longer appears in
GET /links, and its statistics return404 not_found. - You can restore it from Trash in the web app within 30 days. After that it is deleted permanently. The API has no restore endpoint.
- Deleting frees its place in Links per account, and in Custom links for a custom link, straight away. It does not give back New links per day.
- Deleting the same
idagain returns404 not_found. - If you shorten the same URL again after deleting it, you get the same short code back, together with the click statistics it had before, and the deleted copy can then no longer be restored from Trash.
Errors
| Status | Code | When |
|---|---|---|
| 404 | not_found | There is no link with this id in your account, or it is already in Trash. |
Not available in API v1
API v1 has only the four endpoints above. There are no endpoints to get a single link without its statistics, edit a link, restore a link from Trash, create QR codes, create or delete links in bulk, use custom domains, register webhooks, export analytics or manage tokens.
Errors
An error returns a JSON object with a machine-readable code and a message for people in error:
{
"code": "invalid_url",
"error": "A valid http(s) URL is required in \"url\"."
}
Branch on the HTTP status and code. The error text may change.
Not every error is JSON. A wrong path, a trailing slash or a wrong HTTP method returns an HTML page with status 404 or 405. An unexpected server failure can return 500 with {"error": "Internal server error"} and no code. The web server in front of the API answers with HTML pages too: 413 for a request body over 1 MB, and 502, 504 or a Cloudflare error page while the service cannot be reached; retry those later, as you would a 503. Check the status before you parse the body.
Error codes
| Status | Code | Meaning |
|---|---|---|
| 400 | invalid_json | The body is not a JSON object sent with Content-Type: application/json. |
| 400 | invalid_url | url is missing or invalid. |
| 400 | invalid_slug | custom is not a string or breaks the custom link rules. |
| 400 | slug_too_short | custom is shorter than 6 characters. |
| 400 | reserved_slug | custom is a reserved word. |
| 400 | invalid_description | description is not a string or is longer than 500 characters. |
| 400 | invalid_limit | limit is not a whole number from 1 to 100. |
| 400 | invalid_cursor | cursor was not returned by the API. |
| 401 | missing_token | No Authorization: Bearer header. See Authentication. |
| 401 | invalid_token | The token is malformed, unknown or revoked, for example after you regenerated it. |
| 403 | account_blocked | Your account is blocked. Contact support. |
| 403 | domain_blocked | The destination domain has been blocked by gjw.cx. |
| 403 | daily_limit_reached | New links per day reached. Try again after 00:00 UTC+7. |
| 403 | link_limit_reached | Links per account reached. Delete links you no longer need. |
| 403 | custom_limit_reached | Custom links reached. Delete a custom link you no longer need. |
| 404 | not_found | No link with this id in your account. |
| 404 | user_not_found | The account no longer exists (POST /shorten only). |
| 409 | slug_taken | The custom code is already in use, in any letter case. |
| 413 | payload_too_large | The request body is larger than 16 KB (16,384 bytes). |
| 429 | rate_limited | Too many requests per hour or per second for your account, or more than 30 per second from your IP address. |
| 429 | concurrency_limited | Too many requests from your account at the same time. |
| 429 | server_busy | The API is busy right now. |
| 429 | ip_blocked | More than 20 invalid tokens in a minute from your IP address. It is blocked for up to 5 minutes. |
| 429 | temporarily_throttled | More than 50 requests rejected with 429 rate_limited or concurrency_limited in one minute. Your account or IP address is paused for 30 seconds. |
| 500 | internal_error | An unexpected server error. |
| 503 | timeout | The request took too long. |
| 503 | unavailable | The service is temporarily unavailable. |
| 503 | slug_unavailable | A unique short code could not be generated, or the same custom code was being created by another request. |
Retrying
- On
429and503, wait the number of seconds in theRetry-Afterheader, then try again.slug_unavailablehas noRetry-After; you can send it again right away. - Do not repeat other
4xxrequests unchanged. A403limit error means stop until the limit resets; a401means check or regenerate your token. - Retrying immediately makes things worse: more than 50 requests rejected with
rate_limitedorconcurrency_limitedin a minute pause your account or IP address for 30 seconds. - Send requests one after another, or only a few in parallel, and stay within the Requests per second and Concurrent requests of your plan. A loop without a pause easily goes over 10 requests per second. Wait about 150 ms between requests, as the Apps Script example does, or retry on
429as the Python example does.
Retry-After: 1
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 958
X-RateLimit-Reset: 1791460800
{"code": "rate_limited", "error": "Rate limit exceeded: 10 requests per second."}
Limits by plan
What you can do depends on your plan. Your current usage is shown on app.gjw.cx/api-token.
| Limit | Basic | Standard | Premium | Unlimited |
|---|---|---|---|---|
| Links | ||||
| New links per day | 10 | 200 | Unlimited | Unlimited |
| Links per account | 200 | 2,000 | 20,000 | Unlimited |
| Custom links | 3 | 8 | 190 | Unlimited |
| API requests | ||||
| Requests per hour | 1,000 | 1,000 | 1,000 | Unlimited |
| Requests per second | 10 | 10 | 10 | 50 |
| Concurrent requests | 5 | 5 | 5 | 10 |
Scroll the table sideways to see all plans.
How limits are counted
| Limit | What counts | Resets | When exceeded |
|---|---|---|---|
| New links per day | New links you shorten on the website, with the browser extensions, with bulk shortening or through the API. Deleting a link does not give it back. Shortening a URL that you already have as a regular link (sent without a custom code) returns that link and does not count. | 00:00 UTC+7 | 403 daily_limit_reached |
| Links per account | The links you have now, however they were made. Links in Trash do not count. | When you delete a link | 403 link_limit_reached |
| Custom links | The custom links you have now, not counting Trash. Each one also counts as a link. | When you delete one | 403 custom_limit_reached |
| Requests per hour | Every API request with a valid token, errors included, also concurrency_limited and server_busy. Requests rejected with rate_limited or temporarily_throttled do not count. | At the start of each clock hour | 429 rate_limited, Retry-After until the hour ends |
| Requests per second | The same requests, per clock second. | Every second | 429 rate_limited, Retry-After: 1 |
| Concurrent requests | Your requests that are in progress at the same time. | When a request finishes | 429 concurrency_limited, Retry-After: 1 |
- Unlimited means no quota. Requests per second, concurrent requests and system protection still apply to every plan, including at most 30 requests per second from one IP address, so one server cannot go above 30 per second.
- On plans with an hourly quota, responses carry
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset(the Unix time when the hour ends); they are not sent on Unlimited or on requests rejected before the quota check, such as a401. - System protection can also answer
429 server_busy,temporarily_throttled(after more than 50 requests rejected withrate_limitedorconcurrency_limitedin a minute) orip_blocked(after more than 20 invalid tokens in a minute): wait the number of seconds inRetry-After, then try again.
Pagination
GET /links returns at most limit links (default 50, maximum 100) and a next_cursor. To read the next page, send the same request with cursor set to that value. When next_cursor is null, you have all your links.
# First page
curl "https://api.gjw.cx/api/v1/links?limit=100" \
-H "Authorization: Bearer YOUR_API_TOKEN"
# Next page: pass next_cursor from the previous response
curl "https://api.gjw.cx/api/v1/links?limit=100&cursor=NEXT_CURSOR" \
-H "Authorization: Bearer YOUR_API_TOKEN"
- The cursor is opaque. Pass it back unchanged; any other value returns
400 invalid_cursor. - Links are ordered by
created_at, newest first. A link that has only a date comes after the links of that day that have a time. Links created through the API while you page through appear on the first page and do not shift the pages that follow. - Each page is one request and counts toward your hourly quota.
The Python example shows a complete loop.
Custom links
Send custom with POST /shorten to choose the short code yourself, for example https://gjw.cx/SpringLaunch. The link works as soon as it is created.
- Length: 6 to 100 characters.
- Characters: letters
A-Zanda-z, digits0-9, hyphen-and underscore_. Nothing else: no spaces, no., no accented or other non-ASCII letters, no emoji and no symbols such as/ ? # % & + = @ : ;. A code of digits only, such as20261008, is allowed. - Hyphen and underscore: not as the first or last character, and not next to each other.
spring-saleandspring_sale_2026are valid;-spring,spring_,spring--saleandspring-_saleare not.-and_are different characters, sospring-sale,spring_saleandspringsaleare three different codes. - Letter case: ignored.
SpringSaleandspringsaleare the same code: when one exists, the other returns409 slug_taken, andhttps://gjw.cx/springsaleopens the link created asSpringSale. The code keeps the letter case you created it with. - Exact: a short link opens only at its own code. Characters are never removed or replaced, so
https://gjw.cx/spring.saleshows Not Found even whenspringsaleexists. - Unique: a code can belong to only one link across all gjw.cx accounts, generated codes included. A code that is already in use returns
409 slug_taken. A code in Trash is not reserved: if it is used again, the link in Trash can no longer be restored. - No reuse: a request with
customalways creates a new link or fails. It never returns an existing link. - Limits: a custom link counts toward Custom links, New links per day and Links per account.
These reserved words return 400 reserved_slug, in any letter case:
about admin analyze api api-docs api-token blog contact create css dashboard extensions favicon.ico home image index js login manage-links manage-qr notfound notifications pricing privacy robots.txt s signup sitemap.xml static status terms trash user-info v1
Examples
Complete examples for common integrations. Replace YOUR_API_TOKEN with your token, or better, load it from a secret store, and abc123 with the id of one of your links.
curl
# Create a short link
curl -X POST https://api.gjw.cx/api/v1/shorten \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/a/very/long/page",
"description": "Newsletter"}'
# List your links, newest first
curl "https://api.gjw.cx/api/v1/links?limit=100" \
-H "Authorization: Bearer YOUR_API_TOKEN"
# Click statistics for one link
curl https://api.gjw.cx/api/v1/link/abc123/stats \
-H "Authorization: Bearer YOUR_API_TOKEN"
# Move a link to Trash
curl -X DELETE https://api.gjw.cx/api/v1/link/abc123 \
-H "Authorization: Bearer YOUR_API_TOKEN"
Add -i to see the status line and the X-RateLimit-* headers.
JavaScript (Node.js 18 or later)
Uses the built-in fetch. Run it on a server; do not put your token in browser code.
const API_BASE = 'https://api.gjw.cx/api/v1';
const API_TOKEN = 'YOUR_API_TOKEN'; // better: process.env.GJW_API_TOKEN
async function gjw(method, path, body) {
let res;
for (let attempt = 1; attempt <= 3; attempt++) {
res = await fetch(API_BASE + path, {
method,
headers: {
'Authorization': `Bearer ${API_TOKEN}`,
...(body ? { 'Content-Type': 'application/json' } : {})
},
body: body ? JSON.stringify(body) : undefined
});
const wait = Number(res.headers.get('Retry-After')) || 1;
if ((res.status === 429 || res.status === 503) && attempt < 3 && wait <= 30) {
await new Promise((resolve) => setTimeout(resolve, wait * 1000)); // rate limit or busy: wait as told, then retry
continue;
}
break;
}
const data = await res.json().catch(() => null); // 404/405 route errors are HTML
if (!res.ok) {
const err = new Error(`${res.status} ${data?.code ?? ''}: ${data?.error ?? res.statusText}`);
err.status = res.status;
err.code = data?.code;
err.retryAfter = Number(res.headers.get('Retry-After')) || null;
throw err;
}
return data;
}
async function main() {
const link = await gjw('POST', '/shorten', {
url: 'https://example.com/a/very/long/page',
description: 'Newsletter'
});
console.log(link.short_url, link.existing ? '(existing)' : '(new)');
const stats = await gjw('GET', `/link/${link.id}/stats`);
console.log(stats.clicks, 'clicks');
const page = await gjw('GET', '/links?limit=10');
console.log(page.links.length, 'links, next cursor:', page.next_cursor);
}
main().catch((err) => console.error(err.message));
Python (requests)
import time
import requests
API_BASE = "https://api.gjw.cx/api/v1"
API_TOKEN = "YOUR_API_TOKEN" # better: os.environ["GJW_API_TOKEN"]
session = requests.Session()
session.headers["Authorization"] = f"Bearer {API_TOKEN}"
def gjw(method, path, **kwargs):
for attempt in range(1, 4):
r = session.request(method, API_BASE + path, timeout=30, **kwargs)
wait = int(r.headers.get("Retry-After") or 1)
if r.status_code in (429, 503) and attempt < 3 and wait <= 30:
time.sleep(wait) # rate limit or busy: wait as told, then retry
continue
break
try:
data = r.json()
except ValueError: # 404/405 route errors are HTML
data = {}
if not r.ok:
raise RuntimeError(f"{r.status_code} {data.get('code')}: {data.get('error', r.reason)}")
return data
def shorten(url, custom=None, description=None):
body = {"url": url}
if custom:
body["custom"] = custom
if description:
body["description"] = description
return gjw("POST", "/shorten", json=body) # json= sends Content-Type: application/json
def all_links():
links, cursor = [], None
while True:
params = {"limit": 100}
if cursor:
params["cursor"] = cursor
page = gjw("GET", "/links", params=params)
links.extend(page["links"])
cursor = page["next_cursor"]
if cursor is None:
return links
link = shorten("https://example.com/a/very/long/page", description="Newsletter")
print(link["short_url"], "existing" if link["existing"] else "new")
stats = gjw("GET", f"/link/{link['id']}/stats")
print(stats["clicks"], "clicks,", stats["qr_scans"], "from the QR code")
print(len(all_links()), "links in the account")
Google Apps Script (Google Sheets)
This script adds a gjw.cx menu to a sheet. It reads URLs from column A, writes the short links to column B and any error to column C. Three details matter in Apps Script: contentType: 'application/json' together with payload: JSON.stringify(...) (otherwise you get 400 invalid_json), the Authorization: Bearer header, and muteHttpExceptions: true so that you can read error responses.
- In the sheet, open Extensions > Apps Script, paste the code and save.
- Replace
YOUR_API_TOKENinsaveApiToken, runsaveApiTokenonce and allow the access that Apps Script asks for, then put the placeholder back so that your token is not left in the code. The token is now stored in the script properties. - Reload the sheet, put your URLs in column A from row 2, and choose gjw.cx > Shorten URLs in column A.
var GJW_API = 'https://api.gjw.cx/api/v1';
// Run once to store your token in the script properties, then remove the token from this code.
function saveApiToken() {
PropertiesService.getScriptProperties().setProperty('GJW_API_TOKEN', 'YOUR_API_TOKEN');
}
// Adds a "gjw.cx" menu to the sheet.
function onOpen() {
SpreadsheetApp.getUi().createMenu('gjw.cx')
.addItem('Shorten URLs in column A', 'shortenColumnA')
.addToUi();
}
// Shortens one URL and returns the full short link, for example "https://gjw.cx/abc123".
function gjwShorten(longUrl) {
var token = PropertiesService.getScriptProperties().getProperty('GJW_API_TOKEN');
if (!token) throw new Error('No API token. Run saveApiToken() first.');
for (var attempt = 1; attempt <= 3; attempt++) {
var res = UrlFetchApp.fetch(GJW_API + '/shorten', {
method: 'post',
contentType: 'application/json',
headers: { Authorization: 'Bearer ' + token },
payload: JSON.stringify({ url: String(longUrl).trim() }),
muteHttpExceptions: true
});
var status = res.getResponseCode();
var body = {};
try { body = JSON.parse(res.getContentText()); } catch (e) {} // route errors are HTML
if (status === 200 || status === 201) return body.short_url;
var wait = retryAfterSeconds(res);
if ((status === 429 || status === 503) && attempt < 3 && wait <= 30) {
Utilities.sleep(wait * 1000);
continue;
}
var error = new Error(status + ' ' + (body.code || '') + ': ' + (body.error || 'Unexpected response'));
error.code = body.code;
throw error;
}
}
function retryAfterSeconds(res) {
var headers = res.getHeaders();
for (var name in headers) {
if (name.toLowerCase() === 'retry-after') return Math.max(1, Number(headers[name]) || 1);
}
return 1;
}
// Errors that would repeat on every row: stop instead of trying the next row.
var GJW_STOP_CODES = ['missing_token', 'invalid_token', 'account_blocked', 'daily_limit_reached',
'link_limit_reached', 'rate_limited', 'ip_blocked', 'temporarily_throttled'];
// Reads URLs from column A (from row 2), writes short links to column B and errors to column C.
// Rows that already have a short link in column B are skipped, so you can run it again to continue.
function shortenColumnA() {
var sheet = SpreadsheetApp.getActiveSheet();
var lastRow = sheet.getLastRow();
if (lastRow < 2) return;
var rows = sheet.getRange(2, 1, lastRow - 1, 2).getValues();
for (var i = 0; i < rows.length; i++) {
var longUrl = rows[i][0];
if (!longUrl || rows[i][1]) continue;
var row = i + 2;
try {
sheet.getRange(row, 2).setValue(gjwShorten(longUrl));
sheet.getRange(row, 3).clearContent();
} catch (e) {
sheet.getRange(row, 3).setValue(e.message);
if (GJW_STOP_CODES.indexOf(e.code) !== -1) break;
}
Utilities.sleep(150); // stays well under the requests-per-second limit
}
}
- Requests are sent one after another with a short pause, short
429and503waits are retried usingRetry-After, and the script stops on a token problem or a plan limit instead of looping. - A URL you already shortened returns your existing link and does not count toward New links per day. When the daily limit is reached, run the script again after 00:00 UTC+7: rows that already have a short link are skipped.
- Apps Script stops a run after 6 minutes. Choose gjw.cx > Shorten URLs in column A again to continue: rows that already have a short link are skipped.
- Apps Script requests come from IP addresses that Google shares between many scripts, so
ip_blockedor a per-IPrate_limitedcan be caused by other people's scripts. If that happens, wait about 5 minutes and run the script again. short_urlis already the full link. Do not addhttps://gjw.cx/in front of it.
Do not use the web endpoints
The gjw.cx website and browser extensions use internal endpoints under https://api.gjw.cx/api/, such as /shorten-url, /get-urls, /user-info and /delete-url. They accept only a signed-in browser session, their responses differ from API v1, and they are not a public API. The token endpoints /account/api-token also accept only the browser session: manage your token on app.gjw.cx/api-token.
Getting 401 "Invalid or expired token"
This response means the request went to a website endpoint, usually this one:
POST https://api.gjw.cx/api/shorten-url
401 {"error": "Invalid or expired token"}
An API token never works there. Copying the website's session token into your script does not fix it either: sessions expire, and the same error comes back. JSON errors from API v1 always include a code, for example {"code": "invalid_token", "error": "Invalid or revoked API token."}.
If you already send the API v1 body ({"url": ...}) to that endpoint, it answers 400 {"error": "Invalid URL"} instead, also without a code. The fix is the same: send the request to https://api.gjw.cx/api/v1/shorten.
Moving from /api/shorten-url to API v1
- Send
POSTrequests tohttps://api.gjw.cx/api/v1/shorten. - Send your API token as
Authorization: Bearer YOUR_API_TOKEN, and the body as JSON withContent-Type: application/json. - Put the address in
urland a custom code incustom. The old nameslong_urlandcustom_short_urlstill work. - Read
short_urlas the complete short link, for examplehttps://gjw.cx/abc123, and do not addhttps://gjw.cx/in front of it. The code alone is inid. - Treat both
201(new link) and200(existing link) as success.
Changelog
- October 2026
- Custom codes: 6 to 100 characters; hyphens now work in short links;
-and_may not start or end a code or stand next to each other; letter case is ignored when codes are compared, looked up and opened (/link/{id}included);notfoundis now a reserved word.customis no longer trimmed: a code with spaces or other whitespace at the start or end now returns400 invalid_sluginstead of being created without them, and acustomof only spaces is refused instead of giving a generated code. Links created earlier keep working at the code they were created with. - This page was rewritten to match the API as it runs today. Earlier versions of this page, of gjw.cx/api-docs and of the API section on the gjw.cx homepage showed endpoints that are not part of API v1, such as
/api/shorten-url,/api/shorten,/api/urls,/api/stats/{code}and/api/delete/{code}, anX-API-Keyheader, acustom_aliasfield, webhooks and wrong hourly limits. Requests built from those examples fail with 401 or 404. API v1 itself has not changed. gjw.cx/api-docs now redirects to this page.
Questions about the API? Contact us.