EthioLink for Developers: The URL Shortener API

Every link you send to a customer should be created by your system, not by hand. This is the endpoint-by-endpoint guide to the EthioLink REST API — authentication, link creation, analytics, QR codes and click webhooks — with working examples in curl, Node.js and Python.

Sooner or later every Ethiopian business hits the same wall: the links you send to customers are created by a person. Someone copies the URL, pastes it into a shortener, copies the result, pastes it into an SMS template. It works for ten links a day and collapses at two hundred — and it collapses exactly when a campaign is running, which is the worst possible moment.

The EthioLink API replaces that loop: your order system, CMS or campaign tool asks for a short link and gets one back in a single HTTP request, with the code you choose. This guide covers the whole surface — authentication, every endpoint, analytics, QR codes and webhooks — and it assumes you already know how to make an HTTP request. If you would rather read the reference while you build, the API keys page has the same material in condensed form with copy-paste snippets, and the developer hub is the reference itself — every endpoint, the bulk engine, error codes and signed webhook verification.

Getting started

  1. Create an account at short.et/register and confirm your email address.
  2. Move to a plan with API access. API keys are available on the Business (5 keys) and Enterprise (25 keys) plans. Paid plans are activated after an administrator approves the request you submit from the Billing page, so nobody is charged by surprise.
  3. Open the API keys page and create a key. It is shown once — the server only stores a hash of it — so put it in your secret manager immediately.
  4. Make your first request. Any HTTP client works; there is no SDK to install.

Authentication

Data endpoints authenticate with a bearer token:

Authorization: Bearer esk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Key management (GET/POST/DELETE /api/v1/keys) uses your normal login session cookie instead, because creating a key with a key would be a poor idea.

  • Keys are stored as SHA-256 hashes and can be revoked at any time from /keys; a revoked key fails immediately with 401.
  • Keep keys server-side. A key in front-end JavaScript or inside a mobile build is a key that will eventually be copied.
  • A missing header, a malformed header, an unknown key and a revoked key all return 401 with a JSON body — check that body on every call rather than assuming a 2xx means success.
  • Use one key per service (order system, website, reporting job) so a leak can be revoked without disturbing everything else.

Create a link: POST /api/v1/shorten

This is the endpoint you will call most. It accepts three fields:

FieldRequiredNotes
urlyesA valid http:// or https:// address, up to 2048 characters.
custom_codenoYour preferred code, 2–30 characters: Ge'ez letters, Latin letters, digits, hyphen or underscore. code is accepted as an alias.
titlenoA label for your own dashboard, up to 120 characters. Visitors never see it.
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 response 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-09-15T10:00:00.000Z"
  }
}

Note alt_code: the automatic Latin twin, returned on every link that has one. Your integration should store both and pick per channel — short_url for apps and messaging that handle Unicode, the twin for SMS gateways and legacy systems. To build the twin's address, swap the last path segment: https://short.et/zena. The multilingual guide explains the transliteration rule behind it.

Enterprise accounts with an uploaded QR logo also receive qr_url_custom — a ready-made URL for a QR code carrying their own logo in the centre.

What the errors mean

StatusWhenWhat to do
400Missing or invalid url; a code that is too short, too long, or contains unsupported characters.Fix the input — the message names the problem.
401Missing, malformed or revoked key.Check the header, then rotate the key.
403The code is reserved to a brand or institution, or your plan does not include API access.Pick another code (or submit an ownership claim); check the plan on the Billing page.
409The code is already taken by someone else.Retry with a different code — do not blindly resend the same request.
429Per-key rate limit reached (see Limits).Back off briefly, then retry.

Create many at once: POST /api/v1/shorten/bulk

When you are importing a product catalogue or preparing a campaign, send up to 50 URLs in one request:

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/coffee/yirgacheffe","custom_code":"buna"},
        {"url":"https://your-shop.example.com/coffee/sidamo"}
      ]}'

Each entry accepts the same three fields as the single endpoint, and you get an array back with a count:

{
  "ok": true,
  "count": 2,
  "links": [
    {"short_url":"https://short.et/buna","short_code":"buna","original_url":"…","clicks":0,"created_at":"…"},
    {"short_url":"https://short.et/አዲስ","short_code":"አዲስ","alt_code":"…","original_url":"…","clicks":0,"created_at":"…"}
  ]
}

A batch counts as one request against your rate limit, which makes bulk the right tool for imports — but validate your list first, because a single invalid URL fails the whole call.

List, read, update and delete your links

EndpointWhat it does
GET /api/v1/linksYour links, newest first, paginated. Query parameters: q (search across destination, code, twin and title), page, limit. The response carries count, total, page, pages and the links array.
GET /api/v1/links/:codeOne link. :code matches either the main code or its Latin twin — useful when all your integration stored was the twin.
PATCH /api/v1/links/:codeChange where a link points: {"url":"https://new-destination.example/page"}. The code and the accumulated click count survive, which is the whole reason a printed short link is worth having.
DELETE /api/v1/links/:codeDelete the link. The code becomes free again and its QR code starts returning 404.
# Point an already-printed code at a new page
curl -X PATCH https://short.et/api/v1/links/buna \
  -H "Authorization: Bearer esk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://your-shop.example.com/coffee/new-harvest"}'

Per-link analytics: GET /api/v1/links/:code/analytics

The totals are useful; the shape is what you build reports from:

{
  "ok": true,
  "analytics": {
    "code": "buna",
    "url": "https://your-shop.example.com/coffee/yirgacheffe",
    "clicks": 412,
    "total": 412,
    "today": 18,
    "daily": [ { "day": "2026-09-02", "count": 12 } ],
    "adv": {
      "totals": { "clicks": 412, "unique_visitors": 318 },
      "traffic": { "direct": 240, "referral": 172 },
      "referrers": [ { "source": "telegram", "host": "t.me", "clicks": 96 } ],
      "devices": [ { "device": "mobile", "clicks": 370 } ],
      "browsers": [ ... ],
      "os": [ ... ],
      "hourly": [ ... 24 buckets ... ],
      "peak_hours": [ { "hour": 20, "clicks": 44 } ],
      "geo": { "available": true, "mapped": 398, "unknown": 14,
               "countries": [ { "code": "ET", "country": "Ethiopia", "clicks": 366 } ] },
      "visitors": { "unique_visitors": 318, "repeat_visitors": 61, "return_rate": 0.19 },
      "paths": [ ... visitor journeys across your links ... ]
    }
  }
}

Two honest notes about this data, because they affect how you present it. Geographic data is country level and derived offline from the visitor's address — it separates Ethiopia from Kenya, not Addis Ababa from Adama, and like any IP-based estimate it can be wrong for VPN users and for a small number of misregistered network ranges. And geo.mapped versus geo.unknown tells you how many clicks could be placed at all, which is the number to quote when someone asks for percentages.

QR codes: GET /api/qr/:code

Every link has a QR code at a public endpoint. It needs no API key, which is deliberate: you can embed it in an <img> tag, a print template or an email without putting a credential somewhere you cannot protect.

ParameterValuesWhat it does
scale4–40Pixels per module. 8 is roughly 300px; 32 clears 1024px for print. Out-of-range values are clamped.
logodefault, mono, brand, custom, noneCentre logo: the EthioLink mark, the flag-coloured mark, your uploaded workspace logo (Enterprise), or nothing at all if you are laying your own artwork over the code.
dl1Adds a download header and filename, so curl or a browser saves the SVG instead of rendering it.
<!-- Embed it anywhere; the code keeps working if 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 buna-qr.svg "https://short.et/api/qr/%E1%89%A1%E1%8A%93?scale=32&dl=1"

The QR always encodes the link's current short URL, so a reprint is never needed after a destination change. Responses are cached for an hour, which is why a logo or size change belongs in the URL rather than being edited in place.

Click webhooks (Enterprise)

Polling an analytics endpoint to find out that something happened is a bad habit. Enterprise accounts can register an endpoint and receive a POST for every click:

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"}'

# → {"ok":true,"webhook":{"id":7,"url":"…","secret":"…","events":"click",
#      "hint":"Store the secret now - verify x-ethiolink-signature = sha256=HMAC(secret, body)."}}

Each delivery carries:

Header / bodyValue
x-ethiolink-eventclick — the only event type today.
x-ethiolink-signaturesha256= followed by the HMAC-SHA256 of the raw request body, keyed with your webhook secret.
Body{"code":"buna","at":"2026-09-15T18:04:11.000Z","referrer":"t.me","country":"ET","device":"mobile","browser":"Chrome","os":"Android"}

Verify the signature against the raw bytes before you parse anything, and compare in constant time. Return a 2xx to acknowledge: a non-2xx response or a timeout queues a retry, with up to five attempts before the event is abandoned. Deliveries run on a short scheduler rather than instantly, so treat webhooks as near-real-time notifications and not as your analytics source of truth — the analytics endpoints are that.

// Node.js: verify, then act
const crypto = require('crypto');
app.post('/hooks/ethiolink', express.raw({ type: '*/*' }), (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();
});

Rate limits and quotas

Limits are per key, per minute — not per month, and not a hidden credit system:

PlanAPI keysRequests per minute, per key
Free—No API access
Starter—No API access
Business560
Enterprise25300

Exceeding the limit returns 429 with a JSON body. Handle it the boring way: exponential backoff with jitter, and a queue for bulk work. A burst of 500 links belongs in /shorten/bulk (50 at a time), not in 500 parallel requests.

Working examples

Three integrations doing the same thing — creating a link and reading its twin — in the three tools Ethiopian teams actually use.

curl

curl -s -X POST https://short.et/api/v1/shorten \
  -H "Authorization: Bearer $ETHIOLINK_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/orders/8842"}' \
  | python3 -c 'import json,sys; l=json.load(sys.stdin)["link"]; print(l["short_url"], l.get("alt_code",""))'

Node.js (no dependencies)

const KEY = process.env.ETHIOLINK_KEY;

export async function shorten(url, customCode) {
  const res = await fetch('https://short.et/api/v1/shorten', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ url, custom_code: customCode }),
  });
  const data = await res.json();
  if (!res.ok || data.ok === false) throw new Error(data.error || `HTTP ${res.status}`);
  // data.link.short_url  -> short.et/ዜና   (apps, messaging, web)
  // data.link.alt_code   -> zena          (SMS gateways, print, legacy)
  return data.link;
}

// Example: an order-confirmation SMS that stays inside one 70-character segment
const link = await shorten('https://shop.example.com/orders/8842');
const sms = `ትዕዛዝዎ ተልኳል። ይከታተሉ፦ https://short.et/${link.alt_code}`;

Python

import os, requests

KEY = os.environ["ETHIOLINK_KEY"]

def shorten(url, custom_code=None, title=None):
    body = {"url": url}
    if custom_code: body["custom_code"] = custom_code
    if title:       body["title"] = title
    r = requests.post(
        "https://short.et/api/v1/shorten",
        headers={"Authorization": f"Bearer {KEY}"},
        json=body,
        timeout=10,
    )
    data = r.json()
    if r.status_code >= 400 or not data.get("ok"):
        raise RuntimeError(data.get("error", f"HTTP {r.status_code}"))
    return data["link"]

def analytics(code):
    r = requests.get(
        f"https://short.et/api/v1/links/{code}/analytics",
        headers={"Authorization": f"Bearer {KEY}"},
        timeout=10,
    )
    return r.json()["analytics"]

link = shorten("https://shop.example.com/orders/8842", custom_code="ዜና")
print(link["short_url"], link["alt_code"])          # https://short.et/ዜና  zena
print(analytics(link["short_code"])["adv"]["geo"]["countries"])

Amharic codes need URL-encoding when they appear inside a path you build by hand: encodeURIComponent('ዜና') in JavaScript, urllib.parse.quote('ዜና') in Python. JSON bodies are sent as UTF-8 and need no special handling — but if you assemble a URL string yourself, encode the code segment.

Four integrations worth building

Order and shipment tracking

When an order ships, generate one link per order and put it in the SMS. The customer receives a short, safe-looking address, and support stops answering "what was the tracking link again?". Because the destination can be patched later, a carrier change does not invalidate messages you already sent.

Channel-by-channel campaign tracking

Create one link per channel — Telegram, Facebook, the printed flyer, the radio read-out — and compare them in a single report. Give each channel its own code (telegrampromo, flyer0926) instead of reusing one, and let the per-link analytics do the attribution. If your analytics platform needs UTM parameters, keep them on the destination URL: the short link itself is already the marker.

SMS that stays in one segment

An SMS containing Ge'ez characters carries 70 characters per segment rather than 160. Automating short links means the link costs ten characters instead of sixty, so the message reaches the customer without being split into a second, billable part.

Content publishing

Wire the shorten call into your CMS: paste the destination as you draft, and the short link is generated and stored with the article. When the article moves to a new permalink, patch the short link instead of reprinting everything that referenced it.

Where to look next

The API keys page is the reference: it lists every endpoint, the QR parameters, and copy-paste examples including the Amharic URL-encoding case. For the language features — and the transliteration rule behind alt_code — read the multilingual guide; for the product overview, see What is EthioLink?. If something in this guide does not match what your key returns, that is a bug worth reporting — the contact details are in our Terms of Service.

Ready to integrate?

Create an account, request a plan with API access, and put a key to work. The first endpoint to call is the one you will call forever: POST /api/v1/shorten.

Get an API key

Business and Enterprise plans include API access. Free accounts can start on the dashboard today.

Open the API docs Sign up free
X Facebook LinkedIn Telegram

Written by the EthioLink team

We build and run short.et — an Ethiopian URL shortener with Ge'ez and Latin codes, click analytics and an API. Corrections and feature requests are welcome through the contact details in our Terms of Service.