Developer documentation

gjw.cx API v1

Create short links, list them, read their click statistics and delete them from your own server, script or spreadsheet. Plain HTTPS and JSON, authenticated with one API token.

Base URL
https://api.gjw.cx/api/v1
Authentication
Bearer API token
Format
JSON over HTTPS
Availability
Every plan
On this page

Overview

API v1 has four endpoints. Every request goes to the base URL below and carries your API token.

Base URL
https://api.gjw.cx/api/v1
MethodPathWhat it does
POST/shortenCreate a short link, or get back your existing link for the same URL.
GET/linksList the links in your account, newest first.
GET/link/{id}/statsGet one link with its click statistics.
DELETE/link/{id}Move a link to Trash.

Conventions

  • JSON over HTTPS. Responses are JSON. POST /shorten takes a JSON object and needs the header Content-Type: application/json; a form-encoded or plain-text body returns 400 invalid_json. Request bodies are limited to 16 KB.
  • Exact paths. Paths are case-sensitive and have no trailing slash. The /api part belongs to the base URL, so https://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_at is "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_click and last_click are 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:

Header
Authorization: Bearer YOUR_API_TOKEN

Get a token

  1. Sign in and open app.gjw.cx/api-token.
  2. 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.
  3. 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 Bearer exactly like this, with a capital B and one space before the token. bearer, Token, a bare token, an X-API-Key header, a query parameter or a cookie are not accepted and return 401 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:

Request
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:

Response 201
{
  "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.

POSThttps://api.gjw.cx/api/v1/shorten

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

FieldTypeDescription
urlstring, requiredThe 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.
customstring, optionalYour 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.
descriptionstring, optionalA 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

Request
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"}'
Response 201
{
  "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

FieldTypeDescription
idstringThe short code exactly as it was created, used in /link/{id} paths. Letter case is ignored when you use it in a path.
short_urlstringThe full short link: https://gjw.cx/ followed by id.
long_urlstringThe destination URL.
descriptionstringThe description, or "".
titlestringAlways "" in this response. GET /links and the statistics endpoint return the stored title, which is "Untitled" for links created through the API.
created_atstring"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_custombooleantrue when the link has a custom code.
clicksintegerAlways 0 in this response. Use Get link statistics for click counts.
existingbooleantrue 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.

Response 200, existing link
{
  "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

StatusCodeWhen
400invalid_jsonThe body is missing or is not a JSON object sent with Content-Type: application/json.
400invalid_urlurl is missing or is not a valid http or https URL.
400invalid_slugcustom 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.
400slug_too_shortcustom is shorter than 6 characters.
400reserved_slugcustom is a reserved word. See Custom links.
400invalid_descriptiondescription is not a string or is longer than 500 characters.
403domain_blockedThe destination domain has been blocked by gjw.cx.
403daily_limit_reachedYou reached New links per day. See Limits by plan.
403link_limit_reachedYou reached Links per account.
403custom_limit_reachedYou reached Custom links.
404user_not_foundThe account no longer exists.
409slug_takenThe custom code is already in use, by you or by another account, in any letter case.
503slug_unavailableA 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.

GEThttps://api.gjw.cx/api/v1/links

Lists the links currently in your account, newest first by created_at (see Pagination). Links in Trash are not included.

Query parameters

ParameterTypeDescription
limitinteger, optionalLinks per page, from 1 to 100. The default is 50.
cursorstring, optionalThe next_cursor value from the previous page, unchanged. See Pagination.

Example

Request
curl "https://api.gjw.cx/api/v1/links?limit=2" \
  -H "Authorization: Bearer YOUR_API_TOKEN"
Response 200
{
  "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

StatusCodeWhen
400invalid_limitlimit is not a whole number from 1 to 100.
400invalid_cursorcursor is not a next_cursor value returned by the API.
GEThttps://api.gjw.cx/api/v1/link/{id}/stats

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

Request
curl https://api.gjw.cx/api/v1/link/abc123/stats \
  -H "Authorization: Bearer YOUR_API_TOKEN"
Response 200
{
  "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:

FieldTypeDescription
clicksintegerAll 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_scansintegerClicks that came through the link's QR code.
first_click, last_clickstring or nullISO 8601 with an offset, or null before the first click.
last_30_daysarray{"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_countriesarrayUp 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_referrersarrayUp 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

StatusCodeWhen
404not_foundThere is no link with this id in your account. A link that belongs to another account also returns 404.
DELETEhttps://api.gjw.cx/api/v1/link/{id}

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

Request
curl -X DELETE https://api.gjw.cx/api/v1/link/abc123 \
  -H "Authorization: Bearer YOUR_API_TOKEN"
Response 200
{
  "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 return 404 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 id again returns 404 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

StatusCodeWhen
404not_foundThere 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:

Response 400
{
  "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

StatusCodeMeaning
400invalid_jsonThe body is not a JSON object sent with Content-Type: application/json.
400invalid_urlurl is missing or invalid.
400invalid_slugcustom is not a string or breaks the custom link rules.
400slug_too_shortcustom is shorter than 6 characters.
400reserved_slugcustom is a reserved word.
400invalid_descriptiondescription is not a string or is longer than 500 characters.
400invalid_limitlimit is not a whole number from 1 to 100.
400invalid_cursorcursor was not returned by the API.
401missing_tokenNo Authorization: Bearer header. See Authentication.
401invalid_tokenThe token is malformed, unknown or revoked, for example after you regenerated it.
403account_blockedYour account is blocked. Contact support.
403domain_blockedThe destination domain has been blocked by gjw.cx.
403daily_limit_reachedNew links per day reached. Try again after 00:00 UTC+7.
403link_limit_reachedLinks per account reached. Delete links you no longer need.
403custom_limit_reachedCustom links reached. Delete a custom link you no longer need.
404not_foundNo link with this id in your account.
404user_not_foundThe account no longer exists (POST /shorten only).
409slug_takenThe custom code is already in use, in any letter case.
413payload_too_largeThe request body is larger than 16 KB (16,384 bytes).
429rate_limitedToo many requests per hour or per second for your account, or more than 30 per second from your IP address.
429concurrency_limitedToo many requests from your account at the same time.
429server_busyThe API is busy right now.
429ip_blockedMore than 20 invalid tokens in a minute from your IP address. It is blocked for up to 5 minutes.
429temporarily_throttledMore than 50 requests rejected with 429 rate_limited or concurrency_limited in one minute. Your account or IP address is paused for 30 seconds.
500internal_errorAn unexpected server error.
503timeoutThe request took too long.
503unavailableThe service is temporarily unavailable.
503slug_unavailableA unique short code could not be generated, or the same custom code was being created by another request.

Retrying

  • On 429 and 503, wait the number of seconds in the Retry-After header, then try again. slug_unavailable has no Retry-After; you can send it again right away.
  • Do not repeat other 4xx requests unchanged. A 403 limit error means stop until the limit resets; a 401 means check or regenerate your token.
  • Retrying immediately makes things worse: more than 50 requests rejected with rate_limited or concurrency_limited in 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 429 as the Python example does.
Response 429, headers and body
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 day10200UnlimitedUnlimited
Links per account2002,00020,000Unlimited
Custom links38190Unlimited
API requests
Requests per hour1,0001,0001,000Unlimited
Requests per second10101050
Concurrent requests55510

Scroll the table sideways to see all plans.

How limits are counted

LimitWhat countsResetsWhen exceeded
New links per dayNew 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+7403 daily_limit_reached
Links per accountThe links you have now, however they were made. Links in Trash do not count.When you delete a link403 link_limit_reached
Custom linksThe custom links you have now, not counting Trash. Each one also counts as a link.When you delete one403 custom_limit_reached
Requests per hourEvery 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 hour429 rate_limited, Retry-After until the hour ends
Requests per secondThe same requests, per clock second.Every second429 rate_limited, Retry-After: 1
Concurrent requestsYour requests that are in progress at the same time.When a request finishes429 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-Remaining and X-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 a 401.
  • System protection can also answer 429 server_busy, temporarily_throttled (after more than 50 requests rejected with rate_limited or concurrency_limited in a minute) or ip_blocked (after more than 20 invalid tokens in a minute): wait the number of seconds in Retry-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.

Request
# 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.

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

Shell
# 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.

gjw.mjs
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)

gjw.py
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.

  1. In the sheet, open Extensions > Apps Script, paste the code and save.
  2. Replace YOUR_API_TOKEN in saveApiToken, run saveApiToken once 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.
  3. Reload the sheet, put your URLs in column A from row 2, and choose gjw.cx > Shorten URLs in column A.
Code.gs
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 429 and 503 waits are retried using Retry-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_blocked or a per-IP rate_limited can be caused by other people's scripts. If that happens, wait about 5 minutes and run the script again.
  • short_url is already the full link. Do not add https://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:

Wrong endpoint
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

  1. Send POST requests to https://api.gjw.cx/api/v1/shorten.
  2. Send your API token as Authorization: Bearer YOUR_API_TOKEN, and the body as JSON with Content-Type: application/json.
  3. Put the address in url and a custom code in custom. The old names long_url and custom_short_url still work.
  4. Read short_url as the complete short link, for example https://gjw.cx/abc123, and do not add https://gjw.cx/ in front of it. The code alone is in id.
  5. Treat both 201 (new link) and 200 (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); notfound is now a reserved word. custom is no longer trimmed: a code with spaces or other whitespace at the start or end now returns 400 invalid_slug instead of being created without them, and a custom of 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}, an X-API-Key header, a custom_alias field, 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.