API reference
Create and measure tinyurl.et links from your own systems: receipts, articles, campaigns, app installs.
Introduction
The API is JSON over HTTPS. Every endpoint lives under https://tinyurl.et/api/v1. Send Content-Type: application/json on requests with a body. Times are ISO 8601 in UTC.
API access is included with every paid plan. You need an active plan and an API key, created in Dashboard → API keys.
Authentication
Send your key as a bearer token:
Authorization: Bearer tu_live_3kq0…- Keys are shown once when created. We store only a hash, so a lost key can't be recovered: revoke it and create a new one.
- Keep keys on your server. Never ship them in a mobile app or web page.
- Use one key per system (receipts service, CMS…) so you can revoke one without touching the others.
Limits and quotas
Two limits apply, both set by your plan:
- Requests per day, per key, reset at 00:00 UTC. Every response carries
X-RateLimit-Limit,X-RateLimit-RemainingandX-RateLimit-Reset(Unix seconds). Going over returns429 rate_limited. - Links per calendar month, shared between the dashboard and the API. Going over returns
429 quota_exceeded.
See pricing for each plan's numbers. Business API customers can ask for higher limits.
Errors
Errors use standard HTTP status codes and a stable machine-readable code:
{ "error": { "code": "alias_taken", "message": "tinyurl.et/selam-shop is already taken." } }| Field | Type | Description |
|---|---|---|
| unauthorized | 401 | Missing, invalid or revoked API key. |
| plan_inactive | 402 | The account has no active plan. Renew in Billing. |
| feature_unavailable | 403 | The plan doesn't include this (e.g. device routing on Starter). |
| not_found | 404 | No link with that code on your account. |
| domain_not_found / domain_not_ready | 404 / 409 | The domain isn't on your account, or its DNS isn't finished yet. |
| immutable | 405 | Tried to edit a link. Links can't be changed. |
| alias_taken / alias_reserved | 409 | The alias exists or is reserved by tinyurl.et. |
| validation_error / invalid_url / invalid_alias | 400 / 422 | The body is malformed. message says which field. |
| blocked_domain | 422 | The destination domain is blocked for abuse. |
| missing_params | 422 | A template placeholder wasn't supplied. |
| rate_limited / quota_exceeded | 429 | Daily request limit or monthly link quota reached. |
Create a link
POST/api/v1/links
| Field | Type | Description |
|---|---|---|
| url | string, required | Destination for everyone without a device-specific route. Must be http(s). |
| domain | string | One of your live custom domains, e.g. dashen.et. Omit to use your default domain; send "tinyurl.et" to force the shared one. |
| alias | string | Custom code, 2–63 letters, numbers, - or _. Case-sensitive. Omit for a random 6-character code. |
| title | string | Label for your own reference, up to 255 characters. |
| alias_from_title | boolean | Turn title into the alias (see below). |
| ios_url | string | Where iPhones and iPads go. App Store link or a deep link like myapp://item/42. Pro and up. |
| android_url | string | Where Android phones go. Play Store link or a deep link. Pro and up. |
| expires_at | ISO datetime | Optional. After this time the link shows an expired page. |
| metadata | object | Any JSON you want back on reads, e.g. your own IDs. Not shown to visitors. |
| password | string | Visitors must enter it before being sent on. At least 4 characters; stored hashed. |
| max_clicks | number | The link stops working after this many visits. |
| variants | array | A/B rotation: 2–5 objects { url, weight, label }. See below. |
| redirect_type | string | direct (default), splash or cta. See landing pages below. |
| landing | object | Content for splash/cta pages: { title, message, button_text, button_url, delay }. |
| utm | object | { source, medium, campaign, term, content } added to every destination as utm_* parameters. |
| pixel_ids | array | IDs of retargeting pixels (Dashboard → Pixels) to fire before the redirect. |
| campaign | string | Campaign slug or id to file the link under. |
curl -X POST https://tinyurl.et/api/v1/links \
-H "Authorization: Bearer $TINYURL_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://selam.example.com/shop",
"alias": "selam-shop",
"ios_url": "https://apps.apple.com/app/id000000000",
"android_url": "https://play.google.com/store/apps/details?id=com.example.selam"
}'Response 201 Created:
{
"data": {
"code": "selam-shop",
"short_url": "https://tinyurl.et/selam-shop",
"display_url": "tinyurl.et/selam-shop",
"url": "https://selam.example.com/shop",
"ios_url": "https://apps.apple.com/app/id000000000",
"android_url": "https://play.google.com/store/apps/details?id=com.example.selam",
"title": null,
"kind": "api",
"metadata": null,
"clicks": 0,
"disabled": false,
"expires_at": null,
"created_at": "2026-10-26T09:14:03.000Z"
}
}Alias from a title
For blogs and news sites: pass the post title and alias_from_title: true. The title is lower-cased and dashed; if the result is taken, a number is added.
{
"url": "https://hana.example.com/posts/1842",
"title": "Coffee prices in Addis, 2026",
"alias_from_title": true
}
// → tinyurl.et/coffee-prices-in-addis-2026
// → tinyurl.et/coffee-prices-in-addis-2026-2 (if the first was taken)Bulk create
POST/api/v1/links/bulk
Up to 100 links per call, each with the same fields as a single create. Items succeed or fail independently; the response is 207 Multi-Status with one result per input, in order.
// request
{ "links": [ { "url": "https://a.example.com" }, { "url": "https://b.example.com", "alias": "selam-shop" } ] }
// response
{
"data": [
{ "index": 0, "ok": true, "data": { "code": "Hq3mZt", "short_url": "…" } },
{ "index": 1, "ok": false, "error": { "code": "alias_taken", "message": "…" } }
],
"meta": { "created": 1, "failed": 1 }
}List links
GET/api/v1/links?page=1&limit=50
Newest first. limit is at most 100. The response has data and meta: { page, limit, total, pages }.
Get a link
GET/api/v1/links/{code}
Returns the same object as create, including the current clicks total. For a link on a custom domain, add ?domain=dashen.et: the same code can exist once per domain. This also applies to /stats and DELETE.
Click stats
GET/api/v1/links/{code}/stats
Clicks from link-preview bots (Telegram, WhatsApp, Facebook…) are counted separately in bot_clicks and left out of everything else.
{
"data": {
"code": "selam-shop",
"total_clicks": 1284,
"bot_clicks": 37,
"last_clicked_at": "2026-10-26T08:51:12.000Z",
"daily": [ { "day": "2026-09-27", "clicks": 31 }, … ], // last 30 days
"devices": [ { "label": "MOBILE", "count": 1022 }, { "label": "DESKTOP", "count": 262 } ],
"os": [ { "label": "Android", "count": 811 }, … ],
"browsers": [ … ],
"countries": [ { "label": "ET", "count": 1170 }, … ],
"referrers": [ { "label": "t.me", "count": 402 }, … ],
"routes": [ { "label": "android", "count": 640 }, { "label": "ios", "count": 211 }, { "label": "default", "count": 433 } ]
}
}QR code
GET/api/v1/links/{code}/qr
Returns the link's QR code as SVG, in the style saved in the dashboard: the link's own style, otherwise your account default (colours, shape and logo). Query parameters override it for one request.
| Field | Type | Description |
|---|---|---|
| size | number | Width and height in pixels, 64–2048. Default 512. SVG scales cleanly, so this mostly sets the default display size. |
| fg / bg | hex colour | Code and background colours, e.g. fg=%230d7a4a. Keep strong contrast so phones can scan it. |
| style | string | square, rounded or dots. |
| logo | 0 | logo=0 leaves your logo out. |
| domain | string | For links on a custom domain, as with the other link endpoints. |
curl "https://tinyurl.et/api/v1/links/rcpt-ft26300abc12/qr?size=600" \
-H "Authorization: Bearer $TINYURL_KEY" -o receipt-qr.svgWith a logo, codes use the highest error-correction level, so up to about 30% of the code can be covered and it still scans.
Switch off a link
DELETE/api/v1/links/{code}
Switches the link off permanently. Visitors see a “link switched off” page. The code stays reserved, so it can never be reused for a different destination. PATCH and PUT return 405 immutable.
Templates
A template is a destination with placeholders, registered once. Then each link needs only the values that change. This is how a bank creates a link for every receipt without building URLs in its core system.
POST/api/v1/templates
| Field | Type | Description |
|---|---|---|
| slug | string, required | Key you'll pass as template. Lowercase letters, numbers, dashes. |
| name | string, required | Human-readable name. |
| destination_pattern | string, required | URL with {placeholders}. Values are URL-encoded when filled. |
| alias_pattern | string | Optional alias with placeholders, e.g. rcpt-{ref}. Lower-cased. Omit for random codes. |
{
"slug": "receipt",
"name": "Transfer receipt",
"destination_pattern": "https://receipts.example-bank.et/view?ref={ref}",
"alias_pattern": "rcpt-{ref}"
}List them with GET /api/v1/templates; each comes back with its params. You can also manage templates in Dashboard → Templates.
Create from a template
POST/api/v1/links/from-template
| Field | Type | Description |
|---|---|---|
| template | string, required | The template slug. |
| params | object, required | Values for every placeholder, e.g. { "ref": "FT26300ABC12" }. |
| alias | string | Overrides the template's alias pattern. |
| title, ios_url, android_url, expires_at, metadata | Same as a normal create. template and params are added to metadata automatically. |
curl -X POST https://tinyurl.et/api/v1/links/from-template \
-H "Authorization: Bearer $TINYURL_KEY" \
-H "Content-Type: application/json" \
-d '{ "template": "receipt", "params": { "ref": "FT26300ABC12" }, "metadata": { "branch": "Bole" } }'Password, click limits and A/B rotation
All of these are set when the link is created and, like everything else about a link, can't be changed later.
- Password: visitors see a password page first. After the right password, they're let through for 12 hours.
- Click limit: after
max_clickshuman visits the link shows a “limit reached” page. Link previews from bots don't count. - A/B rotation: each visitor is sent to one variant, picked by weight. iPhone and Android routes still win when set. Stats show clicks per variant.
{
"url": "https://shop.example.et/sale",
"alias": "sale",
"variants": [
{ "url": "https://shop.example.et/sale?layout=a", "weight": 70, "label": "Grid" },
{ "url": "https://shop.example.et/sale?layout=b", "weight": 30, "label": "List" }
],
"max_clicks": 5000
}Splash and call-to-action pages
With redirect_type: "splash", visitors see your heading and message, then continue after delay seconds (0 = they click). With "cta", your button comes first (for example “Join our Telegram”) and they can still continue to the destination.
{
"url": "https://example.et/article",
"redirect_type": "cta",
"landing": {
"title": "Before you read",
"message": "Get the next one in your inbox.",
"button_text": "Join our Telegram",
"button_url": "https://t.me/example"
}
}UTM tags, pixels and campaigns
utmaddsutm_source,utm_medium,utm_campaign,utm_termandutm_contentto the destination (and every A/B variant). Existing parameters are kept.pixel_idsfires your Meta, Google, TikTok, LinkedIn, Snap, Pinterest or X pixels on a brief “taking you to…” screen before the redirect, so you can retarget visitors.campaigngroups links: the dashboard shows their combined clicks, and a campaign can have a public page listing all its links.
Custom domains
On the Business API plan, links can use your own domain: dashen.et/rcpt-ft26300abc12 instead of tinyurl.et/…. You add the domain once in Dashboard → Domains; we check its DNS, route it, and issue its HTTPS certificate automatically.
- Send
"domain": "dashen.et"when creating a link, or make the domain your default so every link uses it. - Codes are unique per domain.
dashen.et/promoandtinyurl.et/promoare different links. - The bare domain (
https://dashen.et/) can redirect to your main website. Set this on the domain's page. - Only short links are served on your domain. The dashboard and API stay on tinyurl.et.
DNS setup
Each domain needs two records. The exact values, including your verification token, are on the domain's page in the dashboard.
| Field | Type | Description |
|---|---|---|
| TXT _tinyurl.<your domain> | ownership | tinyurl-verify=<token>. Proves the domain is yours. |
| CNAME go.dashen.et | subdomain | Points a subdomain to cname.tinyurl.et. Recommended: your main website is untouched. |
| A dashen.et | bare domain | Points a bare domain to our server IP addresses (shown in the dashboard). Remove other A/AAAA records on it. |
We re-check pending domains every few minutes, and the domain switches on by itself once both records are found. HTTPS certificates are issued automatically right after, usually within minutes.
List domains
GET/api/v1/domains
{
"data": [
{ "hostname": "dashen.et", "status": "active", "ready": true, "default": true, "https": true, "links": 1840 }
]
}Webhooks
Add an endpoint in Dashboard → Webhooks and pick events. We send a POST with a JSON body within seconds of the event, with a 5-second timeout. After 20 failures in a row the webhook is paused.
| Field | Type | Description |
|---|---|---|
| link.created | event | A link was created (dashboard, API, import). |
| link.clicked | event | A person opened a link. Bots and link previews are not sent. |
| X-Tinyurl-Event | header | The event name. |
| X-Tinyurl-Signature | header | sha256=<HMAC-SHA256 of the raw body, keyed with your signing secret>. |
{
"event": "link.clicked",
"created_at": "2026-10-27T09:14:03.000Z",
"data": {
"link_id": "cm…",
"code": "rcpt-ft26300abc12",
"device": "mobile",
"os": "Android",
"browser": "Chrome",
"country": "ET",
"referrer": "t.me",
"routed_to": "default",
"clicked_at": "2026-10-27T09:14:03.000Z"
}
}Verify the signature before trusting a delivery:
import crypto from "crypto";
function verify(rawBody, header, secret) {
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
return header && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(header));
}Device routing
When a link has ios_url or android_url, we read the visitor's User-Agent on every click:
- iPhone, iPad, iPod →
ios_url, if set - Android →
android_url, if set - Everything else, or no route set →
url
Redirects are 302, so browsers don't cache past the click counter. Deep links (myapp://…) work in the device fields; keep url as a normal web page for people without the app. Each click records which route it took; see routes in stats.
Why links can't be edited
A short link printed on a receipt or sent in an SMS has to keep meaning the same thing. If links could be edited, a leaked API key could quietly point every receipt at a phishing page. So there is no update endpoint. Wrong link? Switch it off and create a new one.
Free public links
The homepage uses POST /api/public/shorten with { "url": "…" } and no key. Each visitor gets one active link at a time, valid for 30 days, with a per-network cap. It's meant for people, not integrations: use an API key for anything automated.