Standalone Django service so callers can mint links and phones get a 302. Closes #1.
7.4 KiB
URL shortener — caller API
For other services (monica_site, later callers) that mint short links.
This service has two public hostnames. Call the API host. Never create links
on the short domain (aiml.pw / cidinn.li / go.mkdrealtor.com). That host
only serves GET / (landing) and GET /<code> (302). /api/ there is 404.
| Host | Example | What you call |
|---|---|---|
| API | https://shortener.aimloperations.com |
POST/GET /api/links/ |
| Short | https://aiml.pw (or cidinn.li) |
phones only — GET /<code> |
Local compose: API + redirects on http://127.0.0.1:8005.
JSON in/out. CSRF-exempt. Server-to-server only — no CORS *. Do not call
this from a browser.
1. Onboard a new caller
Two sides. A named token is the only lock. Anyone who has the URL still cannot mint without it.
1a. This service (operator)
-
Generate a secret (do not reuse
DJANGO_SECRET_KEYor a webhook secret):python -c "import secrets; print(secrets.token_urlsafe(32))" -
Pick a short token name for the caller (
monica,scha,chat, …). Revoking one name does not rotate the others. -
Append
name:secrettoSHORTENER_API_TOKENS(comma-separated). Redeploy or restart so settings reload.SHORTENER_API_TOKENS=monica:<secret>,scha:<other-secret>Prod/beta files:
~/Documents/secrets/url_shortening_service/url_shortening_service_prod.env ~/Documents/secrets/url_shortening_service/url_shortening_service_beta.env -
If the caller’s destination hosts are not already allowed, add them to
SHORT_ALLOWED_HOSTS(exact or suffix). Example:mkdrealtor.comalso allowswww.mkdrealtor.com.httpsonly. -
Give the caller only:
- API origin (
SHORTENER_BASE_URL) - The full token string
name:secret(they send it as Bearer)
Never put the token in git, logs, or the short URL.
- API origin (
Empty SHORTENER_API_TOKENS → every /api/ request is 503 (fail closed).
1b. Caller service (your repo)
Add to that app’s env (not this repo):
# Prod
SHORTENER_BASE_URL=https://shortener.aimloperations.com
SHORTENER_API_TOKEN=monica:<same-secret>
# Beta (when that host exists)
# SHORTENER_BASE_URL=https://shortener-beta.aimloperations.com
# SHORTENER_API_TOKEN=monica:<beta-secret>
# Local (this service via compose)
# SHORTENER_BASE_URL=http://127.0.0.1:8005
# SHORTENER_API_TOKEN=monica:dev-only-token
Send:
Authorization: Bearer monica:<secret>
That value must match an entry in this service’s SHORTENER_API_TOKENS
(name:secret). Bare secret also works; prefer name:secret.
The token name (monica) is stored on the row as created_by_token.
The secret is never stored.
Attach UTM (or any query string) on target_url before you mint. The
short code is a pointer; it does not rewrite query params later.
2. Auth
Every /api/ route requires:
Authorization: Bearer <name>:<secret>
Content-Type: application/json
| Situation | Status | Body |
|---|---|---|
| Missing or wrong Bearer | 401 | {"detail":"Unauthorized"} + WWW-Authenticate: Bearer |
| No tokens configured on the server | 503 | {"detail":"Service unavailable"} |
| Valid token | continues | — |
401 does not distinguish “unknown token” vs “malformed header”.
3. POST /api/links/
Mint a short link.
Request
POST /api/links/
Authorization: Bearer monica:<secret>
Content-Type: application/json
{
"target_url": "https://mkdrealtor.com/listings/oak-st?utm_source=monica&utm_medium=sms",
"title": "Oak St listing",
"external_ref": "campaign-uuid-optional",
"expires_at": null
}
| Field | Required | Notes |
|---|---|---|
target_url |
yes | https only. Host must match SHORT_ALLOWED_HOSTS. No http, javascript:, data:, //evil.com, or user:pass@host. |
title |
no | Note, max 200 chars. |
external_ref |
no | Your id (campaign UUID), max 64. Empty → always mint a new code. |
expires_at |
no | ISO-8601 datetime or null. After this, GET /<code> is 404. |
Response 201 (new)
{
"code": "a3k9xm",
"short_url": "https://aiml.pw/a3k9xm",
"target_url": "https://mkdrealtor.com/listings/oak-st?utm_source=monica&utm_medium=sms",
"title": "Oak St listing",
"is_active": true,
"click_count": 0,
"created_at": "2026-08-30T10:00:00Z"
}
short_url is built from this service’s PUBLIC_SHORT_URL (the domain phones
hit). It is not the API host. Put short_url in SMS / email as-is.
Idempotency 200
Same target_url + non-empty external_ref + still-active (and unexpired)
link → existing row, 200, no new code.
Empty external_ref → always 201 and a new code.
Errors
| Status | When |
|---|---|
| 400 | Invalid JSON, bad URL, host not allowlisted, bad expires_at / title / ref |
| 401 | Auth |
| 503 | No tokens on server |
| 500 | Could not allocate a unique code (rare) |
There is no PATCH of target_url and no DELETE. Disable instead.
4. GET /api/links/
List. Query params:
| Param | Default | Notes |
|---|---|---|
external_ref |
— | Exact match |
is_active |
— | true / false (or 1 / 0) |
limit |
20 | Capped at 100 |
offset |
0 |
{
"count": 1,
"limit": 20,
"offset": 0,
"results": [ { "code": "a3k9xm", "short_url": "…", "…": "…" } ]
}
5. GET /api/links/<code>/
One link, including click_count. 404 if the code does not exist.
6. POST /api/links/<code>/disable/
Sets is_active=false. Idempotent. 200 with the updated row.
After disable, public GET /<code> is 404 (no redirect).
7. What the public does (not the API)
GET https://<SHORT_DOMAIN>/<code> — no token.
- Active + unexpired → 302 to
target_url(not 301). Click counted. - Missing / disabled / expired / bad shape → 404.
GET /→ landing page.
Do not put the Bearer token on this URL.
8. Local smoke
This service:
cp .env.example .env # SHORTENER_API_TOKENS=monica:dev-only-token
docker compose up --build
Caller (or curl):
export SHORTENER_BASE_URL=http://127.0.0.1:8005
export SHORTENER_API_TOKEN=monica:dev-only-token
curl -sS -X POST "${SHORTENER_BASE_URL}/api/links/" \
-H "Authorization: Bearer ${SHORTENER_API_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"target_url":"https://mkdrealtor.com/","title":"test"}'
Expect 201 and short_url like http://127.0.0.1:8005/<code>.
curl -sSI "${SHORTENER_BASE_URL}/<code>"
Expect 302 and Location: https://mkdrealtor.com/.
No header → 401. https://evil.com → 400.
9. Minimal caller (Python)
import os
import requests
BASE = os.environ["SHORTENER_BASE_URL"].rstrip("/")
TOKEN = os.environ["SHORTENER_API_TOKEN"]
def shorten(target_url: str, *, title: str = "", external_ref: str = "") -> str:
response = requests.post(
f"{BASE}/api/links/",
headers={"Authorization": f"Bearer {TOKEN}"},
json={
"target_url": target_url,
"title": title,
"external_ref": external_ref,
},
timeout=10,
)
response.raise_for_status()
return response.json()["short_url"]
Use short_url in the message body. On 401/503, fail the send — do not
fall back to pasting the API host into SMS.