The Ethiopian URL shortener API

Everything an integration needs: bearer key authentication, link creation, the bulk shortening engine, link management, per-link analytics, QR encoding, and signed webhooks you can verify in a few lines. There is no SDK to install and no client library to keep in step — it is JSON over HTTPS.

ለገንቢዎች የተዘጋጀ የኤፒአይ ማጣቀሻ — በአማርኛም ሆነ በእንግሊዝኛ።

Quick start

  1. Create an account and confirm your email at /register.
  2. 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.
  3. 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.
  4. 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/keys and DELETE /api/keys/:id authenticate with your normal login cookie — creating a key with a key would be a poor idea.

Every endpoint

MethodEndpointWhat it doesPlan
POST/api/v1/shortenCreate one link, with an optional custom code (Ge'ez or Latin) and titleBusiness+
POST/api/v1/shorten/bulkCreate up to 50 links in one callBusiness+
GET/api/v1/linksList your links, paged, with a search termBusiness+
GET/api/v1/links/:codeFetch one link — accepts the Latin twin as well as the codeBusiness+
PATCH/api/v1/links/:codeChange a link's destination, keeping the code and its printed QRBusiness+
DELETE/api/v1/links/:codeDelete a linkBusiness+
GET/api/v1/links/:code/analyticsClicks plus the advanced breakdown for that linkBusiness+
GET/api/keys · POST /api/keys · DELETE /api/keys/:idKey management with your session cookieBusiness+
GET/api/qr/:codeSVG QR code for a link; scale, logo and dl parametersPublic
GET · POST · DELETE/api/webhooks · /api/webhooks/:idRegister, list and remove click webhooksEnterprise

Create a link

The endpoint you will call most. Three fields, all optional except the destination:

FieldRequiredNotes
urlyesA valid http:// or https:// address. Anything else is rejected with 400, which is what stops a link from becoming an open redirect.
custom_codenoYour preferred code: Ge'ez or Latin letters, digits, hyphen or underscore. code is accepted as an alias, so the dashboard field name works too.
titlenoA 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 returns 404 (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 urls array is 400 "Provide a urls array."; 51 items is 400 "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

CallDetail
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/:codeOne 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/:codeReturns { ok: true, deleted: "<code>" }. The code is freed; the click history goes with it.
GET /api/v1/links/:code/analyticsClicks 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.

StatusWhen it happensWhat to do
400Missing 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.
401Missing, malformed, unknown or revoked key.Check the header, then rotate the key at /keys.
403The 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.
404No 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.
409The code is already taken by someone else.Choose another code; treat it as "already created" during an import.
429The 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.
500An 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:

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

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:

ParameterValuesEffect
scale4–40Pixels per module. 8 is roughly 300px; 32 clears 1024px for print. Out-of-range values are clamped rather than rejected.
logodefault, mono, brand, custom, noneCentre 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.
dl1Adds 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 / 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. Compare it in constant time.
user-agentethiolink-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.

Build on short.et

Business gives you five keys at 60 requests per minute each; Enterprise raises that to twenty-five keys at 300, and adds signed webhooks and team workspaces.

Create an account Compare plans
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. Integration questions reach a human at the addresses on our Terms of Service.