jst.pw API
Create short links, change where they go and read their clicks from your own code. JSON over HTTPS, one key per app.
Getting started
The base URL for every request is:
https://jst.pw/api/v1
- Get a key. Sign in, open Dashboard → API and create a key. It is shown once, so copy it somewhere safe. Keys start with
jst_live_. - Send JSON. Request bodies are JSON (
Content-Type: application/json); form encoding also works. Every response is JSON, errors included. - Everything is yours. A key acts as the account that made it and only ever sees that account's links.
curl https://jst.pw/api/v1/me \
-H "Authorization: Bearer jst_live_your_key_here"
Authentication
Send the key as a Bearer token. The X-Api-Key header works too.
Authorization: Bearer jst_live_your_key_here
X-Api-Key: jst_live_your_key_here
Abilities
| Key type | Can |
|---|---|
| Read only | Every GET endpoint: your account, your links, their stats and clicks. |
| Read & write | Everything above, plus creating, updating and deleting links. |
A key can expire (after 30, 90 or 365 days, or never) and can be revoked from the dashboard at any time; either way it stops working on the next request. Your plan sets how many keys can be active at once: no limit, no limit.
Keep keys on your server. Anyone holding one can act as your account.
Errors
Every error has the same shape, whatever the status:
{
"error": {
"code": "validation_failed",
"message": "The given data was invalid.",
"details": {
"errors": { "url": ["This domain is not allowed."] }
}
}
}
| Status | Code | When |
|---|---|---|
| 401 | unauthenticated | No key, or a key that is unknown, revoked or expired. details.reason says which: missing_key, invalid_key, revoked_key, expired_key. |
| 403 | insufficient_ability | A read-only key called an endpoint that writes. |
| 403 | api_access_disabled | API access was switched off for this account. The key itself is fine. |
| 403 | account_suspended | The account has been suspended. |
| 403 | link_disabled_by_admin | You tried to re-activate a link that jst.pw disabled. |
| 404 | not_found | No such endpoint, or no link with that slug on your account. A slug that belongs to someone else is also 404. |
| 405 | method_not_allowed | The endpoint exists but not with this HTTP method. |
| 422 | validation_failed | A field is missing or wrong. details.errors lists messages per field. |
| 429 | rate_limited | Too many requests this minute. Wait Retry-After seconds. |
| 500 | server_error | Something broke on our side. Safe to retry a GET. |
| 503 | maintenance | jst.pw is down for maintenance. Retry-After is sent when we know when it will be back. |
Rate limits
Limits are per account, across all of its keys, counted per minute:
- Free: 360 requests a minute.
- Business: 600 requests a minute.
- Requests without a valid key: 10 a minute per IP address.
Limited responses carry these headers. Past the limit you get 429 with Retry-After (seconds) and details.retry_after.
X-RateLimit-Limit: 360
X-RateLimit-Remaining: 359
GET /me tells you your current limit.
Pagination
Lists take page (from 1) and per_page (default 25, at most 100), newest first, and answer:
{
"data": [ ... ],
"meta": { "current_page": 1, "per_page": 25, "total": 132, "last_page": 6, "from": 1, "to": 25 },
"links": { "next": "https://jst.pw/api/v1/links?page=2", "prev": null }
}
Follow links.next until it is null.
Endpoints
| Method | Path | Key |
|---|---|---|
| GET | /me | read |
| GET | /links | read |
| POST | /links | write |
| GET | /links/{slug} | read |
| PATCH | /links/{slug} | write |
| DELETE | /links/{slug} | write |
| GET | /links/{slug}/stats | read |
| GET | /links/{slug}/clicks | read |
The link object
{
"slug": "aB3xK",
"short_url": "https://jst.pw/aB3xK",
"name": "Spring launch",
"destination": "https://example.com/spring",
"direct": true,
"password_protected": false,
"active": true,
"status": "active",
"clicks": 1284,
"last_clicked_at": "2026-09-17T10:42:03+00:00",
"expires_at": null,
"created_at": "2026-09-01T08:00:00+00:00",
"updated_at": "2026-09-10T14:21:55+00:00",
"qr": {
"png": "https://jst.pw/aB3xK/qr.png",
"svg": "https://jst.pw/aB3xK/qr.svg"
}
}
direct is true when visitors go straight to the destination with a 301, false when they see the waiting page first. status is active, disabled or expired. A password is never returned, only whether one is set.
Your account, plan, limits and what is left this month.
curl https://jst.pw/api/v1/me -H "Authorization: Bearer $JST_KEY"
{
"data": {
"account": { "id": 42, "name": "Mona", "email": "mona@example.com" },
"plan": "free",
"plan_expires_at": null,
"limits": { "direct_monthly": null, "rules_per_link": 1, "dynamic_qr": 1, "pages": 1, "api_rate_per_minute": 360, "api_keys": null },
"usage": {
"direct_monthly": { "used": 12, "limit": null, "remaining": null, "resets_at": "2026-10-01T00:00:00+00:00" },
"api_keys": { "used": 1, "limit": null }
},
"rate_limit": { "per_minute": 360 },
"key": { "name": "Newsletter script", "prefix": "jst_live_aB3", "abilities": ["read", "write"], "expires_at": null }
}
}
Your links, newest first, Pagination.
| Query | Description |
|---|---|
q | Search the slug, name and destination. |
active | true or false. |
page, per_page | See Pagination. |
curl "https://jst.pw/api/v1/links?q=spring&per_page=50" -H "Authorization: Bearer $JST_KEY"
Shorten a URL. Answers 201 with the link object and meta.direct_quota_reached.
| Field | Type | Description |
|---|---|---|
url | string, required | The destination, up to 2048 characters. example.com is read as https://example.com. Checked like the website: http or https only, no blocked domains, and the domain must exist. |
name | string | A label for you, up to 120 characters. |
password | string | 3 to 72 characters. Visitors must enter it before they are forwarded. |
direct | boolean | Default true: an instant 301 while your monthly allowance lasts. false shows the waiting page. See Instant redirects. |
curl -X POST https://jst.pw/api/v1/links \
-H "Authorization: Bearer $JST_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/spring", "name": "Spring launch"}'
HTTP/1.1 201 Created
{
"data": { "slug": "aB3xK", "short_url": "https://jst.pw/aB3xK", "destination": "https://example.com/spring", "direct": true, ... },
"meta": { "direct_quota_reached": false }
}
One link, as {"data": {link}}.
curl https://jst.pw/api/v1/links/aB3xK -H "Authorization: Bearer $JST_KEY"
Change any of these; leave out what should stay. Every change applies from the very next visit. Answers the link object and meta.direct_quota_reached.
| Field | Type | Description |
|---|---|---|
url | string | A new destination, checked like on create. |
name | string or null | null clears it. |
password | string or null | Sets a new password; null removes protection. |
direct | boolean | Switch between the instant 301 and the waiting page. Turning it on spends an allowance slot unless this link already spent one this month. |
active | boolean | false pauses the link (visitors get 404). A link disabled by jst.pw cannot be re-activated here. |
curl -X PATCH https://jst.pw/api/v1/links/aB3xK \
-H "Authorization: Bearer $JST_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/summer", "password": null}'
Deletes the link. Answers 204 No Content; the short link stops working immediately and its address is not given to anyone else.
curl -X DELETE https://jst.pw/api/v1/links/aB3xK -H "Authorization: Bearer $JST_KEY"
The same analytics as the dashboard, for the last days days (7, 30 or 90; default 30). Days are counted in UTC; by_hour and by_weekday are in the timezone given.
curl "https://jst.pw/api/v1/links/aB3xK/stats?days=7" -H "Authorization: Bearer $JST_KEY"
{
"data": {
"slug": "aB3xK",
"days": 7,
"since": "2026-09-11T00:00:00+00:00",
"timezone": "Africa/Cairo",
"totals": { "clicks": 311, "uniques": 204, "all_time_clicks": 1284 },
"series": [ { "date": "2026-09-11", "clicks": 40, "uniques": 31 }, ... ],
"breakdowns": {
"countries": [ { "value": "EG", "clicks": 190, "share": 61 }, ... ],
"cities": [ ... ], "referrers": [ ... ], "devices": [ ... ], "browsers": [ ... ], "os": [ ... ]
},
"by_hour": [ 3, 1, 0, ... 24 numbers ],
"by_weekday": { "Sat": 52, "Sun": 48, "Mon": 44, "Tue": 40, "Wed": 41, "Thu": 46, "Fri": 40 }
}
}
Individual clicks, newest first, Pagination. No IP address is stored per click, and the daily visitor hash used to count unique visitors is never returned.
curl "https://jst.pw/api/v1/links/aB3xK/clicks?per_page=100" -H "Authorization: Bearer $JST_KEY"
{
"data": [
{ "created_at": "2026-09-17T10:42:03+00:00", "country": "EG", "city": "Cairo", "referrer": "t.co", "device": "mobile", "os": "Android", "browser": "Chrome", "routing_rule_id": null }
],
"meta": { ... },
"links": { "next": "...", "prev": null }
}
Instant redirects and the waiting page
A jst.pw link either forwards visitors straight away with a 301 (direct: true) or shows the waiting page first. Instant redirects are a monthly allowance: no limit Free on Free, no limit Business on Business.
- The allowance is shared: links made on the website and through the API spend the same slots.
- When it runs out,
POST /linksstill creates the link and answers201, with"direct": falseand"meta": {"direct_quota_reached": true}. The link works; it opens the waiting page. - Deleting a link or switching it back to the waiting page does not give the slot back. The allowance starts again on the first of the month (UTC).
Not in v1
These are dashboard-only for now:
- Routing rules (sending visitors to different places by country, device and so on).
- Managed QR codes. Every link's QR code image is available at the
qrURLs on the link object. - Link-in-bio pages.
If the API doesn't cover what you need yet, contact us.