Files
url_shortening_service/API.md
T
westfarn 433308d615
Deploy Beta / unit-tests (push) Successful in 4s
Deploy Beta / docker (push) Successful in 10s
Deploy Beta / deploy-beta (push) Successful in 1m3s
Serve /api/ on piha.lc / beta.piha.li (#8)
## Summary
- Serve `/api/` on `piha.lc` / `beta.piha.li` (Bearer still required).
- Drop `shortener.aimloperations.com` / `shortener-beta.aimloperations.com` from env examples and caller docs.
- `/admin/` stays 404 on the public host.

Closes #7.

## Test plan
- [ ] `POST https://beta.piha.li/api/links/` with valid Bearer → 201
- [ ] Same without Bearer → 401
- [ ] `GET https://beta.piha.li/<code>` still 302
- [ ] `/admin/` on beta.piha.li → 404
- [ ] Unit tests: `cd site && uv run python manage.py test`

Reviewed-on: #8
2026-08-30 17:27:40 -07:00

7.2 KiB
Raw Blame History

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 /<code>
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):

    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.

    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
    
  4. If the callers 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_URLhttps://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 apps env (not this repo):

# Prod
SHORTENER_BASE_URL=https://piha.lc
SHORTENER_API_TOKEN=monica:<same-secret>

# Beta
# SHORTENER_BASE_URL=https://beta.piha.li
# 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 services 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://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 services 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.


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.com400.


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 a long URL unless that is your explicit fallback.