Quick start
- Create an account and confirm your email at /register.
- Move to a plan with API access. Keys exist on Business (5 keys, 60 requests per minute each) and Enterprise (25 keys, 300 per minute each). Free and Starter have no API access, so the key page stays locked until a paid plan is approved.
- Create a key at /keys. It is shown exactly once — the server keeps only a SHA-256 hash of it — so move it into your secret manager before you close the page.
- Make your first request. Any HTTP client works; there is nothing to install.
curl -X POST https://short.et/api/v1/shorten \
-H "Authorization: Bearer esk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://your-shop.example.com/orders/8842",
"custom_code":"ዜና","title":"Order 8842"}'
The base URL for every data endpoint is https://short.et/api/v1. The API is answered on any host of the service — root or branded — but short.et is the address to hard-code, because it never changes when a workspace renames its branded link.
Authentication
Data endpoints take a bearer token:
Authorization: Bearer esk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
- Keys are hashed, not stored. Only a SHA-256 hash reaches the database, alongside a short prefix so you can identify a key in the dashboard. A leaked database cannot yield a working key.
- Revocation is immediate. Delete the key from /keys and every later request with it fails with
401. - One key per service. Order system, website, reporting job — separate keys mean a leak is revoked without breaking everything else.
- Never ship a key to a browser or an app store. Anything in front-end code is public within a day; keep the key on your server.
- Key management is session-based, not key-based.
POST /api/keys,GET /api/keysandDELETE /api/keys/:idauthenticate with your normal login cookie — creating a key with a key would be a poor idea.
Every endpoint
| Method | Endpoint | What it does | Plan |
|---|---|---|---|
POST | /api/v1/shorten | Create one link, with an optional custom code (Ge'ez or Latin) and title | Business+ |
POST | /api/v1/shorten/bulk | Create up to 50 links in one call | Business+ |
GET | /api/v1/links | List your links, paged, with a search term | Business+ |
GET | /api/v1/links/:code | Fetch one link — accepts the Latin twin as well as the code | Business+ |
PATCH | /api/v1/links/:code | Change a link's destination, keeping the code and its printed QR | Business+ |
DELETE | /api/v1/links/:code | Delete a link | Business+ |
GET | /api/v1/links/:code/analytics | Clicks plus the advanced breakdown for that link | Business+ |
GET | /api/keys · POST /api/keys · DELETE /api/keys/:id | Key management with your session cookie | Business+ |
GET | /api/qr/:code | SVG QR code for a link; scale, logo and dl parameters | Public |
GET · POST · DELETE | /api/webhooks · /api/webhooks/:id | Register, list and remove click webhooks | Enterprise |
Create a link
The endpoint you will call most. Three fields, all optional except the destination:
| Field | Required | Notes |
|---|---|---|
url | yes | A valid http:// or https:// address. Anything else is rejected with 400, which is what stops a link from becoming an open redirect. |
custom_code | no | Your preferred code: Ge'ez or Latin letters, digits, hyphen or underscore. code is accepted as an alias, so the dashboard field name works too. |
title | no | A label for your own dashboard. Visitors never see it. |
Success is 201 Created:
{
"ok": true,
"link": {
"short_url": "https://short.et/ዜና",
"short_code": "ዜና",
"alt_code": "zena",
"original_url": "https://your-shop.example.com/orders/8842",
"clicks": 0,
"created_at": "2026-10-09T10:00:00.000Z"
}
}
Field by field, because three of these decide how your integration stores the link:
short_url— the canonical address, always on the root domain.alt_code— the automatic Latin twin of a Ge'ez code, present whenever the code needed one. Store it: it is the URL that survives SMS gateways and systems that only accept ASCII, and it resolves to the same destination.branded_url— present when the link belongs to a workspace with a branded host. This is the address to publish and to print, because the root-domain URL of a branded link deliberately returns404(tenant isolation, explained on the compliance hub).qr_url_custom— present on Enterprise accounts with an uploaded logo: a ready-made QR URL that carries your mark in the centre.
The bulk engine: POST /api/v1/shorten/bulk
A catalogue import, a campaign with 300 destinations, or a migration from another shortener belongs here rather than in a fan-out of parallel requests. Send an array of up to 50 items per call; each item takes the same three fields as a single create.
curl -X POST https://short.et/api/v1/shorten/bulk \
-H "Authorization: Bearer esk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"urls":[
{"url":"https://your-shop.example.com/products/1001","custom_code":"ሸጋ1001","title":"Shega 1001"},
{"url":"https://your-shop.example.com/products/1002","title":"Shega 1002"},
{"url":"https://your-shop.example.com/products/1003","custom_code":"shega1003"}
]}'
# → 201 {"ok":true,"count":3,"links":[{"short_url":"…","short_code":"ሸጋ1001",
# "alt_code":"shega1001","original_url":"…","clicks":0,"created_at":"…"}, …]}
Three behaviours worth knowing before you wire it into a job:
- 50 is a hard cap. A missing or empty
urlsarray is400"Provide a urls array."; 51 items is400"Bulk limit is 50 links per call." Chunk your input at 50 and you will never see either. - It is sequential and fails fast. Items are created in order, and the first invalid one ends the call with its own error. Links created earlier in that batch are already saved — so on a failure, do not blindly resend the whole batch: reconcile the codes you already received (or simply skip any code that returns
409, which means it already exists). - Rate limits still apply per key. One bulk call of 50 costs one request against the per-minute budget — which is exactly why bulk beats a parallel fan-out for large imports.
Recommended import shape: read your source rows in chunks of 50, POST one chunk, store the returned codes before moving on, and retry a chunk on 429 with exponential backoff plus jitter. That keeps the job restartable and leaves an auditable trail of what was created.
Manage links and read analytics
| Call | Detail |
|---|---|
GET /api/v1/links?q=&page=&limit= | Paged list for the key's account: { ok, count, total, page, pages, links }. q searches codes and destinations; use it for reconciliation instead of downloading everything. |
GET /api/v1/links/:code | One link, and it accepts the Latin twin as well as the Ge'ez code — so a stored alt_code is enough to read the record. |
PATCH /api/v1/links/:code with {"url":"…"} | Repoint a live link. The code, the analytics history and any printed QR code stay valid, which is the whole reason to fix a destination instead of printing a new one. |
DELETE /api/v1/links/:code | Returns { ok: true, deleted: "<code>" }. The code is freed; the click history goes with it. |
GET /api/v1/links/:code/analytics | Clicks for the link plus the advanced breakdown (referrers, countries, devices) for plans that include advanced analytics. |
Errors and rate limits
Every failure is JSON with ok: false and a human-readable error string. Check that field on every call rather than assuming a 2xx means success.
| Status | When it happens | What to do |
|---|---|---|
400 | Missing or invalid url; a code that is too short, too long or contains unsupported characters; a bulk array that is empty or over 50. | Fix the input — the message names the problem. |
401 | Missing, malformed, unknown or revoked key. | Check the header, then rotate the key at /keys. |
403 | The code is reserved to a brand or institution, or your plan does not include API access. | Pick another code, or move the account to Business/Enterprise. |
404 | No such link in this account — a link that belongs to another account is indistinguishable from one that never existed. | Check the code, including the Latin twin, before retrying. |
409 | The code is already taken by someone else. | Choose another code; treat it as "already created" during an import. |
429 | The per-key rate limit for the current minute is exhausted. | Back off with exponential backoff and jitter, then retry. There is no Retry-After header to read. |
500 | An unexpected server-side failure; the body never leaks a stack trace or an internal path. | Retry once, then send the request id and timestamp to the address on our legal pages. |
Limits are per key, per minute — no monthly credit system:
| Plan | API keys | Requests per minute, per key |
|---|---|---|
| Free | — | No API access |
| Starter | — | No API access |
| Business | 5 | 60 |
| Enterprise | 25 | 300 |
The budget is counted per key, not per account, so a reporting job and an order system do not starve each other. Session-based endpoints have a separate per-address write limit; the API is unaffected by it.
QR codes
GET /api/qr/:code returns an SVG QR code — no key, no session, cacheable. It takes three query parameters:
| Parameter | Values | Effect |
|---|---|---|
scale | 4–40 | Pixels per module. 8 is roughly 300px; 32 clears 1024px for print. Out-of-range values are clamped rather than rejected. |
logo | default, mono, brand, custom, none | Centre mark: the EthioLink logo, the flag-coloured mark, your uploaded workspace logo (Enterprise), or nothing at all if you are laying your own artwork over the code. |
dl | 1 | Adds a download header and filename, so a client saves the SVG instead of rendering it. |
<!-- Embed it anywhere; the code keeps working when you change the destination -->
<img src="https://short.et/api/qr/buna?scale=12" width="240" height="240"
alt="QR code for our coffee page">
# Amharic codes work the same way - URL-encode the code
curl -o zena-qr.svg "https://short.et/api/qr/%E1%8B%9C%E1%8A%93?scale=32&dl=1"
Two properties make this safe for print. First, the QR encodes the address that actually resolves: for a link owned by a workspace with a branded host it encodes https://slug.short.et/<code>, and for everything else the root URL — so a poster cannot end up pointing at a 404. Second, the code is addressed by the link's code alone, never by a URL you assemble in a client, so a reprint is never needed after a destination change.
Signed webhooks
Polling analytics to discover that something happened is a bad habit. On Enterprise, register an endpoint once and receive a POST for every click. Registration uses your session cookie, not a key:
curl -X POST https://short.et/api/webhooks \
-H "Cookie: ethiolink_session=…" \
-H "Content-Type: application/json" \
-d '{"url":"https://your-app.example/hooks/ethiolink"}'
# → 201 {"ok":true,"webhook":{"id":7,"url":"https://your-app.example/hooks/ethiolink",
# "secret":"whsec_…","events":"click",
# "hint":"Store the secret now - verify x-ethiolink-signature = sha256=HMAC(secret, body)."}}
The secret is returned once and never again, so store it like a password. GET /api/webhooks lists your endpoints with their last delivery status, and DELETE /api/webhooks/:id removes one. Up to five enabled webhooks per account; only an http or https URL is accepted, and a loopback address is refused outside local development.
Each delivery carries:
| Header / body | Value |
|---|---|
x-ethiolink-event | click — the only event type today. |
x-ethiolink-signature | sha256= followed by the HMAC-SHA256 of the raw request body, keyed with your webhook secret. Compare it in constant time. |
user-agent | ethiolink-webhook/1.0, so you can spot deliveries in your logs. |
| Body | {"code":"buna","at":"2026-10-09T18:04:11.000Z","referrer":"t.me","country":"ET","device":"mobile","browser":"Chrome","os":"Android"} |
Verification is the whole point of the signature: without it, anyone who learns your endpoint URL can feed you fake clicks. Verify the raw bytes before parsing, and fail closed.
Verify in Node.js
const crypto = require('crypto');
const express = require('express');
app.post('/hooks/ethiolink',
express.raw({ type: '*/*' }), // raw body: do not let a JSON parser touch it
(req, res) => {
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.ETHIOLINK_WEBHOOK_SECRET)
.update(req.body)
.digest('hex');
const given = String(req.get('x-ethiolink-signature') || '');
const same = given.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
if (!same) return res.status(401).end();
const event = JSON.parse(req.body.toString('utf8'));
if (event.code === 'buna') notifyWarehouse(event.country);
res.status(200).end(); // 2xx acknowledges the delivery
});
Verify in Python
import hashlib, hmac, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['ETHIOLINK_WEBHOOK_SECRET'].encode()
@app.post('/hooks/ethiolink')
def receive():
raw = request.get_data() # raw bytes, not parsed JSON
given = request.headers.get('x-ethiolink-signature', '')
expected = 'sha256=' + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(given, expected):
abort(401)
event = request.get_json()
if event['code'] == 'buna':
notify_warehouse(event['country'])
return '', 200
Delivery behaviour, so you can size your receiver honestly: events are queued atomically with the click that produced them and sent by a scheduler that runs about once a minute, with an 8-second timeout per attempt and a batch of up to 20 events per run. A 2xx resolves the event; a non-2xx response or a timeout records the failure and retries, and an event is abandoned after five attempts. If every webhook on the account has been deleted, pending events are resolved instead of retried forever. Treat webhooks as near-real-time notifications, not as your analytics source of truth — GET /api/v1/links/:code/analytics is that.
Running code
Three integrations that do the boring parts correctly.
Node.js — a restartable bulk import
const KEY = process.env.ETHIOLINK_KEY;
async function shortenBatch(items) {
const res = await fetch('https://short.et/api/v1/shorten/bulk', {
method: 'POST',
headers: { Authorization: 'Bearer ' + KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ urls: items }),
});
if (res.status === 429) return null; // rate limited: back off, retry
const body = await res.json();
if (!res.ok) throw new Error(body.error || ('HTTP ' + res.status));
return body.links; // [{ short_url, short_code, alt_code, … }]
}
async function importAll(rows) {
const saved = [];
for (let i = 0; i < rows.length; i += 50) {
const chunk = rows.slice(i, i + 50);
for (let attempt = 0; attempt < 5; attempt++) {
const links = await shortenBatch(chunk);
if (links) { saved.push(...links); break; }
await new Promise((r) => setTimeout(r, 2 ** attempt * 500 + Math.random() * 250));
}
}
return saved; // store these before moving on
}
Python — one link, then read it back
import os, requests
BASE = 'https://short.et/api/v1'
H = {'Authorization': 'Bearer ' + os.environ['ETHIOLINK_KEY']}
created = requests.post(BASE + '/shorten', headers=H, timeout=10, json={
'url': 'https://your-shop.example.com/orders/8842',
'custom_code': 'ዜና',
'title': 'Order 8842',
})
created.raise_for_status()
link = created.json()['link']
print(link['short_url'], link['alt_code'], link.get('branded_url'))
# The twin code addresses the same record, and analytics reads by code.
stats = requests.get(BASE + '/links/' + link['alt_code'] + '/analytics', headers=H, timeout=10)
print(stats.json()['analytics']['clicks'])
curl — repoint a live link
curl -X PATCH https://short.et/api/v1/links/ዜና \
-H "Authorization: Bearer esk_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{"url":"https://your-shop.example.com/orders/8842?v=2"}'
# 200 {"ok":true,"link":{"short_url":"https://short.et/ዜና","original_url":"…?v=2", …}}
Next steps
Read the developer guide for the worked end-to-end story, the API keys page for the condensed reference inside the product, and the compliance hub if your security team needs the data-handling answers before you ship. Everything above works on the Free plan's equivalents too — the dashboard does the same calls with a session cookie.