# URL shortener — caller API For other services (`monica_site`, later callers) that mint short links. This service has one public hostname. Call it for minting **and** for redirects. Prod: `https://piha.lc`. Beta: `https://beta.piha.li`. | Host | Example | What you call | |------|---------|----------------| | Prod | `https://piha.lc` | `POST /api/links/` and `GET /` | | Beta | `https://beta.piha.li` | same | 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) 1. Generate a secret (do **not** reuse `DJANGO_SECRET_KEY` or a webhook secret): ```bash python -c "import secrets; print(secrets.token_urlsafe(32))" ``` 2. Pick a short token **name** for the caller (`monica`, `scha`, `chat`, …). Revoking one name does not rotate the others. 3. Append `name:secret` to `SHORTENER_API_TOKENS` (comma-separated). Redeploy or restart so settings reload. ```text SHORTENER_API_TOKENS=monica:,scha: ``` Prod/beta files: ```text ~/Documents/secrets/url_shortening_service/url_shortening_service_prod.env ~/Documents/secrets/url_shortening_service/url_shortening_service_beta.env ``` 4. If the caller’s destination hosts are not already allowed, add them to `SHORT_ALLOWED_HOSTS` (exact or suffix). Example: `mkdrealtor.com` also allows `www.mkdrealtor.com`. `https` only. 5. Give the caller **only**: - Origin (`SHORTENER_BASE_URL` — `https://piha.lc` or `https://beta.piha.li`) - The full token string `name:secret` (they send it as Bearer) Never put the token in git, logs, or the short URL. 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): ```text # Prod SHORTENER_BASE_URL=https://piha.lc SHORTENER_API_TOKEN=monica: # Beta # SHORTENER_BASE_URL=https://beta.piha.li # SHORTENER_API_TOKEN=monica: # Local (this service via compose) # SHORTENER_BASE_URL=http://127.0.0.1:8005 # SHORTENER_API_TOKEN=monica:dev-only-token ``` Send: ```http Authorization: Bearer monica: ``` 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: ```http Authorization: Bearer : 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 ```http POST /api/links/ Authorization: Bearer monica: Content-Type: application/json ``` ```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 /` is 404. | ### Response `201` (new) ```json { "code": "a3k9xm", "short_url": "https://piha.lc/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` (same origin you called). 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 | | ```json { "count": 1, "limit": 20, "offset": 0, "results": [ { "code": "a3k9xm", "short_url": "…", "…": "…" } ] } ``` --- ## 5. `GET /api/links//` One link, including `click_count`. **404** if the code does not exist. --- ## 6. `POST /api/links//disable/` Sets `is_active=false`. Idempotent. **200** with the updated row. After disable, public `GET /` is **404** (no redirect). --- ## 7. What the public does (not the API) `GET https:///` — 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: ```bash cp .env.example .env # SHORTENER_API_TOKENS=monica:dev-only-token docker compose up --build ``` Caller (or curl): ```bash 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/`. ```bash curl -sSI "${SHORTENER_BASE_URL}/" ``` Expect `302` and `Location: https://mkdrealtor.com/`. No header → `401`. `https://evil.com` → `400`. --- ## 9. Minimal caller (Python) ```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 a long URL unless that is your explicit fallback.