ServicesLink

Link

A redirect control plane and click hot path: campaigns group short links, and each link is a slug on a branded domain carrying its own policy - scheduling and expiry, click caps, password gates, conditional routing, UTM rules, cloaking, and QR codes. A public GET /r/{slug} applies that policy and issues the redirect. Every example on this page was executed against a live server.

When to reach for it: branded campaign links that carry UTM tags without per-team drift, short-lived password-protected recovery links, QR targets for print and events, mobile deep links with a web fallback, and any public URL you may need to expire, reroute, or quarantine without a deploy.

When not to: a redeemable code with discount rules is Promo Code; a temporary public download URL for a stored file is Blob, which signs its own; and a referral program with points and fraud controls is Loyalty - Link carries only the click.

Concepts

  • A campaign is a policy group - it sets the default branded domain, default UTM parameters, security policy, and agent-context policy its links inherit. Every link belongs to exactly one, and there is no campaign update or delete endpoint, so campaigns are effectively write-once.
  • Slugs are unique per domain, not globally, so the same slug on two domains is two links. Slug text is trimmed, lowercased, and limited to alphanumerics, hyphens, and underscores; nine words are reserved (r, api, admin, links, health, metrics, docs, openapi, ws).
  • Status is what the redirect returns. draft, active, expired, paused, quarantined, and deleted each map to a distinct HTTP response - see Redirects and routing.
  • Two ways to resolve. Authenticated POST /links/{id}/resolve returns the decision as JSON without moving the caller; public GET /r/{slug} performs it. Both record a click. Every update bumps policy_version, and QR generation is keyed to it.
  • Ownership gates mutations only. Update, delete, quarantine, and QR generation require the link's creator or a platform admin; get, list, stats, and resolve are open to any authenticated caller.

Campaigns

A campaign must exist before any link can be created:

snug link campaign create --name docs4-spring --domain go.docs4.example \
  --description "Spring launch acquisition" --utm-source newsletter --utm-medium email

snug link campaign list
snug link campaign get --campaign-id <campaign_id>
snug link domains          # every domain in use, with a verified flag

campaign list -q is a full-text search over campaign name and description; verified live, -q verification matched a campaign by description alone.

snug link create --campaign-id <campaign_id> --url https://app.docs4.example/signup --vanity docs4-signup
snug link create --campaign-id <campaign_id> --url https://app.docs4.example/pricing   # slug generated
snug link get --link-id <link_id>
snug link list --campaign-id <campaign_id> --status active

Over HTTP that create is a POST /api/v1/links:

curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" -H "Content-Type: application/json" \
  -d '{"campaign_id":"MSYuTFSXMxGzUvbTUGtq","slug":"docs4-signup","destination_url":"https://app.docs4.example/signup"}' \
  http://localhost:4000/api/v1/links
{
  "status": 201,
  "msg": "Created",
  "data": {
    "link_id": "aaCyPnLrWLbVxebzqSww",
    "short_url": "https://go.docs4.example/docs4-signup",
    "slug": "docs4-signup",
    "status": "active"
  }
}

Omit slug and an eight-character one is generated. Rejections are 400 (admin is reserved_slug, docs4 launch! is invalid_slug, ftp:// is unsupported_scheme) except a collision, which is 409 slug_conflict. DOCS4-Upper is not rejected at all - it is silently normalized to docs4-upper, so read the slug back rather than assume the casing you sent.

Conditional routes, deep-link fallbacks, and cloaking are set on this same create call but have no CLI flags, so they go over HTTP - see Redirects and routing. Listing filters on campaign_id and status and, over HTTP, sorts on sort_by/sort_order; those are service-specific parameters, not the shared search grammar.

Scheduling, expiry, and click caps

snug link create --campaign-id <id> --url https://app.docs4.example/launch --expires-at 2026-09-01T15:00:00Z
snug link create --campaign-id <id> --url https://app.docs4.example/limited --max-clicks 500

A starts_at in the future creates the link as draft and /r/{slug} answers 403 until that moment, then the first resolve flips it to active lazily. A past expires_at and an exhausted max_clicks both land on expired and 410 Gone - a link with max_clicks: 2 served two redirects and returned expired on the third.

Batch creation

snug link batch-create --file links.json

The file is a bare JSON array of the same objects the create endpoint takes, capped at 500 (400 batch_too_large above it) and rejected when empty.

The batch is fail-fast but not atomic. Verified live with a three-item batch whose second slug collided: the first link persisted, the request returned 409 slug_conflict, and the third was never attempted. Retrying then collides on whatever already landed, so reconcile first.

QR codes

snug link qr --link-id <link_id> --output ./launch-qr.png

The PNG encodes the link's short_url and comes back base64-encoded in image_base64 beside a blob_id and the policy_version it was built from. Despite the name, blob_id is not a Blob key - snug blobs head --path blob_qr_<link_id> returns 404, so save the bytes from the response. -o/--output here is the PNG path, not the global --output json flag.

Stats

snug link stats --link-id <link_id>
snug link campaign stats --campaign-id <campaign_id>

Counters update on the hot path: total_clicks, an outcome_breakdown keyed by redirect outcome, a route_breakdown keyed by conditional-route name, and a HyperLogLog unique_visitors estimate. Uniqueness only counts callers that sent a visitor_hash, so without one unique_visitors stays 0 however many clicks land, while repeating a request_id counts one click and makes retries safe. POST /resolve increments all of these just like a real redirect, so a health check that polls it inflates the numbers.

snug link update --link-id <link_id> --status paused
snug link quarantine --link-id <link_id> --reason "compromised slug" --interstitial-url https://app.docs4.example/warning
snug link delete --link-id <link_id>

Quarantine preserves the counters and routes callers to the interstitial (307), or answers 403 when none is set. delete is a soft delete: the record survives with status deleted and still appears in link list, resolve returns 403 link_inactive, /r/{slug} returns 404, and the slug is released - verified by recreating it immediately afterwards.

Limits and configuration

Slug length (128), routes per link (25), generated slug length (8), batch size (500), redirect-token TTL (300s), the failed-password threshold (10 in a 300s window), the allowed URL schemes, the branded-domain allowlist, and the expiry worker are tunable via the LINK_* variables in the Link CONFIG reference. With LINK_ALLOWED_DOMAINS empty any domain is accepted and every entry in link domains reports verified: false.

Reference

  • Link API - every endpoint, callable
  • Redirects and routing - the hot path, conditional routes, UTM rules, cloaking, passwords, and agent-injected context
  • Related: Promo Code for redeemable codes, Loyalty for referral programs, Blob for stored files with signed URLs

On this page