# buygolinks.com API — full reference Branded short-link service with mobile deep linking and Amazon affiliate geo-routing. All endpoints listed below in a single document, optimized for LLM ingestion. The canonical machine-readable spec is `https://docs.buygolinks.com/openapi.yaml`. ## Base URLs - API: `https://app.buygolinks.com` - Short-link resolver (public, no auth): `https://buygolinks.com/{code}` - Collection rotator (public, no auth): `https://buygolinks.com/c/{alias}` ## Authentication All `/api/*` endpoints require a Bearer token. Two token shapes are accepted: 1. **Personal access token** — `bgl_<24-base62>`. Recommended for programmatic use. Create with `POST /api/api-keys`. The raw token is returned exactly once; we store only SHA-256(token). 2. **Supabase JWT** — issued to the SPA after login. Short-lived; not intended for backend integrations. Send as: ``` Authorization: Bearer ``` ## Error shape All errors are JSON: `{ "error": "" }`. Common HTTP statuses: - `400 bad_json | invalid_url | invalid_url_scheme | url_required | tag_required | invalid_store | invalid_platform | invalid_pixel_id | invalid_alias | invalid_collection | no_changes | name_required` - `401 unauthorized` - `403 forbidden` — resource exists but isn't yours - `404 not_found` - `409 alias_taken` — rotator alias collision - `500 code_collision` — exhausted short-code attempts (extremely rare) --- ## Links ### `GET /api/links` List the caller's links with per-link click counts and current usage. Query params: `include_archived=1` to include archived links (default: only active). Response: ```json { "links": [ { "id": 42, "code": "aB3xY7zQ", "destination_url": "https://www.amazon.com/dp/B08N5WRWNW", "asin": "B08N5WRWNW", "title": "Echo Dot (4th Gen)", "source_label": "ig-bio", "product_image_url": "https://xvacgtizymyqqlpnlvmq.supabase.co/storage/v1/object/public/products/B08N5WRWNW-aB3xY7zQ.jpg", "collection_id": 3, "created_at": "2026-05-27T18:00:00Z", "archived_at": null, "clicks": 128 } ], "short_domain": "buygolinks.com", "usage": { "plan": "free", "links": { "used": 1, "limit": null }, "clicks_30d": { "used": 128, "limit": null } } } ``` ### `POST /api/links` Create a short link. Body: ```json { "destination_url": "https://www.amazon.com/dp/B08N5WRWNW", "title": "Echo Dot", // optional, max 200 "source_label": "ig-bio" // optional, max 60, sanitized to [a-z0-9_-] } ``` If `destination_url` is an Amazon `/dp/{ASIN}` URL, the server: - extracts the ASIN, - best-effort scrapes the product image + title (capped at ~6s), - rehosts the image to our Supabase Storage bucket. Response: ```json { "link": { /* Link object */, "clicks": 0 }, "short_url": "https://buygolinks.com/aB3xY7zQ", "short_domain": "buygolinks.com" } ``` ### `PATCH /api/links/{id}` Edit a link. Body (any subset): ```json { "title": "...", "source_label": "...", "archived": true } ``` `archived: true` sets `archived_at` to now; `archived: false` clears it. ### `DELETE /api/links/{id}` Hard delete. Cascades to `link_clicks` via FK. Returns `{ "ok": true }`. ### `POST /api/links/{id}/collection` Assign a link to a collection, or detach it. Body: ```json { "collection_id": 3 } // or { "collection_id": null } to detach ``` --- ## Statistics Time windows are clamped to 1–90 days; default 14. ### `GET /api/stats/overview?days=14` Single round-trip payload powering the Statistics page. Returns: - `totals` — links / clicks / unique_clicks / unique_referrers / countries / top_referrer / top_country - `comparison` — clicks vs the previous window of equal length (`trend_pct`) - `link_performance` — `health` (0–100), counts of active/growing/declining links, and top growing links with delta_pct - `by_day` — `[{ day: "YYYY-MM-DD", count }]` - `links_created_by_day` - `by_country`, `by_device`, `by_browser`, `by_referrer` — top buckets - `by_hour` — 24 buckets `{ hour, count }` (UTC) - `by_weekday` — 7 buckets `{ name, count }` (Sun…Sat, UTC) - `peak_hour`, `peak_weekday` - `top_links` — top 5 by clicks - `recent_clicks` — last 25 - `window_days` ### `GET /api/stats/link/{id}?days=14` Same shape minus account-wide rollups, plus `by_language` and per-click `ip`. --- ## Amazon affiliate tags The resolver looks up the link owner's tags and picks the best storefront per visitor. If multiple tags exist and `geo_enabled` is true, the visitor's country is mapped to the matching Amazon store. Without geo, it prefers the US tag, then falls back to any active tag, then to plain `amazon.com` with no tag. ### `GET /api/amazon-tags` Returns the caller's tags and the catalog of valid stores (`{ "US": "www.amazon.com", "UK": "www.amazon.co.uk", ... }`). ### `POST /api/amazon-tags` Create or update (upsert keyed on `(owner, store)`). Body: ```json { "store": "US", "tag": "mybrand-20", "active": true, "geo_enabled": true } ``` `store` must be a key from the `stores` catalog. `tag` is sanitized to `[a-z0-9_-]`, max 60. ### `PATCH /api/amazon-tags/{id}` / `DELETE /api/amazon-tags/{id}` Update or delete a single tag. --- ## Collections Group links into folders. If `rotator_enabled` is true, the public URL `https://buygolinks.com/c/{rotator_alias}` picks a random member on each visit. ### `GET /api/collections` List collections with link + click counts. ### `POST /api/collections` Body: ```json { "name": "Top picks", "description": "...", // optional, max 280 "color": "#3b82f6", // hex from a fixed palette; invalid → #ef4444 "starred": false, "rotator_enabled": false, "rotator_alias": "topPicks2026" // 8–24 chars [A-Za-z0-9_-]; auto-generated if omitted } ``` ### `GET /api/collections/{id}?days=14` Collection detail: meta + member links + aggregated stats over the window. ### `PATCH /api/collections/{id}` / `DELETE /api/collections/{id}` Update or delete. Deleting a collection detaches its links (sets `links.collection_id = NULL`); it does not delete the links themselves. --- ## Tracking pixels Meta / TikTok / Google pixels that fire on the deep-link interstitial page. ### `GET /api/tracking-pixels` Returns `pixels` + the platform catalog (`{ label, placeholder, help }` per platform). ### `POST /api/tracking-pixels` Body: ```json { "platform": "meta", "pixel_id": "1234567890", "label": "Main pixel", "active": true } ``` `platform` must be a key from the catalog. `pixel_id` is sanitized per platform. ### `PATCH /api/tracking-pixels/{id}` / `DELETE /api/tracking-pixels/{id}` --- ## API keys Personal access tokens for programmatic API access. ### `GET /api/api-keys` List your keys. The token hash is never returned; you see `id`, `label`, `prefix` (first 8 chars of the random portion, for UI identification), `last_used_at`, `created_at`, `revoked_at`. ### `POST /api/api-keys` Create a new token. Body: ```json { "label": "production server" } // optional, max 80 ``` Response: ```json { "key": { "id": 12, "label": "production server", "prefix": "aB3xY7zQ", ... }, "token": "bgl_aB3xY7zQmN9pK2rT5sW8vCdE" } ``` The `token` field is returned **exactly once**. Save it now — we store only SHA-256(token) and cannot recover it. ### `DELETE /api/api-keys/{id}` Revoke a token. Existing usages will start failing with `401 unauthorized` on the next request. --- ## Custom domains Branded short-link hostnames (e.g. `go.acme.com`) served via Cloudflare for SaaS. Once DNS resolves, Cloudflare auto-issues SSL. Free plan does not include custom domains; paid plans allow 1 (Small), 3 (Medium), or unlimited (Large) domains. ### `GET /api/domains` List your custom domains. Each row is force-refreshed against Cloudflare on each call (except recently-active rows, which are cached for 5 minutes). ```json { "domains": [ { "id": 7, "hostname": "go.acme.com", "cf_hostname_id": "0d89c7…", "status": "active", // pending | active | failed | blocked "ssl_status": "active", "verification_errors": null, "cf_last_synced_at": "2026-05-29T…", "created_at": "2026-05-29T…" } ], "plan": "small", "domain_limit": 1, "cname_target": "buygolinks.com" } ``` ### `POST /api/domains` Register a new branded hostname. Returns instructions for the CNAME the user must add. Body: ```json { "hostname": "go.acme.com" } ``` Errors: - `402 upgrade_required` — free plan, custom domains not allowed. - `402 plan_limit_reached` — already at plan domain cap. - `400 invalid_hostname` — malformed. - `400 reserved_hostname` — tried to claim buygolinks.com or a subdomain of it. - `409 hostname_taken` — already registered by another account. - `502 cloudflare_error` — CF API rejected the create (e.g. zone misconfigured). Response: ```json { "domain": { "id": 7, "hostname": "go.acme.com", "status": "pending", ... }, "cname_target": "buygolinks.com", "instructions": "Add a CNAME record at your DNS provider: go.acme.com CNAME buygolinks.com" } ``` ### `POST /api/domains/{id}/verify` Force a fresh sync against Cloudflare's Custom Hostnames API. Use after the user adds the CNAME record to advance `status` from `pending` to `active`. Returns the refreshed domain row. ### `DELETE /api/domains/{id}` Removes both the Cloudflare hostname binding and the local record. Short links served via this hostname stop resolving immediately. --- ## Public resolver ### `GET https://buygolinks.com/{code}` No auth. Logs a click row (country from `CF-IPCountry`, device + browser from User-Agent, referrer, language) and: - **Amazon link** (link has `asin`): - Build URL `https://{store_host}/dp/{ASIN}?tag={tag}&linkCode=ll1&ascsubtag=link-{code}-{store}` - iOS: returns HTML that fires `com.amazon.mobile.shopping.web://{host}{path}`, falls back to the full URL after 1.8s. - Android: returns HTML that fires `intent://{host}{path}#Intent;package=com.amazon.mshop.android.shopping;...`. - Desktop: 302 directly. - **Non-Amazon link**: 302 directly to `destination_url`. The `ascsubtag` value (`link-{code}-{store}`) shows up in your Amazon Associates report so you can attribute conversions back to a specific short link + storefront. ### `GET https://buygolinks.com/c/{alias}` No auth. Picks a random member of the collection identified by the rotator alias and resolves it as above. ### `GET https://{your-custom-domain}/{code}` If the request arrives on a custom hostname the user has registered via `POST /api/domains` (status=`active`), resolution is scoped to that hostname's owner. Two users can therefore reuse the same short code on different hostnames. Unregistered hostnames return `404 Domain not registered`. --- ## Quick start (curl) ```bash # 1. Create a personal access token via the SPA (Account → API keys), # or with a temporary Supabase JWT: TOKEN="bgl_aB3xY7zQmN9pK2rT5sW8vCdE" # 2. List your links curl -H "Authorization: Bearer $TOKEN" https://app.buygolinks.com/api/links # 3. Create a link curl -X POST https://app.buygolinks.com/api/links \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"destination_url":"https://www.amazon.com/dp/B08N5WRWNW","source_label":"ig-bio"}' # 4. Read 14-day overview curl -H "Authorization: Bearer $TOKEN" \ "https://app.buygolinks.com/api/stats/overview?days=14" ``` --- ## Rate limits & quotas No hard rate limit on `/api/*` today. Free-tier accounts may have link- and click-count caps (see `usage` in `GET /api/links`). Short-link resolution at `buygolinks.com/{code}` is served by Cloudflare's edge and has no soft limit. ## Versioning The API is unversioned. Breaking changes are avoided; additive changes (new fields, new endpoints) ship without notice. Subscribe to `https://docs.buygolinks.com/openapi.yaml` for the canonical contract — diff against your local copy to detect changes.