ServicesLink

Redirects and Routing

Two endpoints run the same policy engine and reach the same decision. The public GET /r/{slug} acts on it and moves the caller; the authenticated POST /api/v1/links/{link_id}/resolve returns it as JSON so a mobile app, a QR preview, or a test can inspect the outcome without following it. Both record a click.

/r/{slug} takes no token. It derives the branded domain from the Host header and looks the slug up under that domain, so the same slug on two domains resolves to two different links - and a request that arrives with the wrong Host is a 404, not a redirect to the wrong place.

curl -i -H "Host: go.docs4.example" http://localhost:4000/r/docs4-signup
HTTP/1.1 307 Temporary Redirect
location: https://app.docs4.example/signup?utm_source=newsletter&utm_medium=email

Outcomes

Link state/r/{slug}resolve outcome
Active, routed307 to locationredirected
Password set, not yet verified401password_required
Cloaked, interstitial set307 to the interstitialcloaked
Cloaked, no interstitial403cloaked
Quarantinedas cloaked, abovecloaked
Draft (before starts_at) or paused403error link_inactive
Past expires_at or over max_clicks410expired
Deleted link404error link_inactive
Unknown slug, or wrong Host404-

Conditional routes

conditional_routes is an ordered list evaluated top to bottom; the first route whose conditions all hold wins, and if none match the link's own destination_url is used. Routes are set over HTTP, not through the CLI:

{
  "campaign_id": "MSYuTFSXMxGzUvbTUGtq",
  "slug": "docs4-deep",
  "destination_url": "https://app.docs4.example/web",
  "conditional_routes": [
    {
      "name": "mobile-app",
      "when": { "device_family": "ios" },
      "destination_url": "docs4app://signup",
      "fallback_url": "https://app.docs4.example/ios-web",
      "skip_utm": true
    },
    {
      "name": "eu-trusted",
      "when": { "reputation_min": "trusted", "country_allowlist": ["DE", "FR"] },
      "destination_url": "https://eu.docs4.example/web"
    }
  ]
}

Four condition types can be combined inside one when, and an empty when matches everything:

  • device_family is an exact string match. The route above fires for ios and falls through for android. A rule meant for both phones needs two routes.
  • reputation_min is a floor on the caller's bucket, ordered malicious < known_bot < suspicious < neutral < trusted. A neutral caller does not satisfy a trusted floor.
  • referrer_class is an exact, case-sensitive match on what the caller sent: Newsletter does not match a rule written as newsletter.
  • country_allowlist, in contrast, is case-insensitive - de satisfies ["DE"].

Resolving the link above shows each branch:

snug link resolve --link-id <link_id> --device-family ios
snug link resolve --link-id <link_id> --reputation trusted --country de
snug link resolve --link-id <link_id> --device-family desktop
Requestselected_routefinal_url
device_family=iosmobile-appdocs4app://signup
reputation=trusted, country=deeu-trustedhttps://eu.docs4.example/web?utm_source=...
device_family=desktopnonehttps://app.docs4.example/web?utm_source=...

Only named routes appear in route_breakdown on the stats endpoint. Clicks that fell through to the default destination are in total_clicks but in no route bucket.

fallback_url is not a generic "if the route fails" branch - it swaps in only when the route's destination_url uses a custom scheme (anything not http:// or https://) and the caller's device_family is neither ios nor android. A route with an empty when and a docs4app://open destination served the app scheme to ios, and the fallback URL to desktop and to a request with no device hint at all.

UTM parameters

UTM merging happens twice, under different rules.

At create time the link's own utm object is merged field-by-field over the campaign's default_utm and the result is stored on the link. Under a campaign defaulting to source=newsletter, medium=email, a link created with {"source":"linklevel","content":"hero"} stored {source: linklevel, medium: email, content: hero}. Since campaigns cannot be updated, that baking-in is permanent for links already created.

At redirect time each stored parameter is appended to the destination only if that query key is not already present. With a destination of https://app.docs4.example/x?utm_source=preset&ref=abc:

https://app.docs4.example/x?utm_source=preset&ref=abc&utm_medium=email&utm_content=hero

utm_source=preset survived and utm_source=linklevel was not appended. Set skip_utm on a route to suppress appending entirely for that branch.

snug link create --campaign-id <id> --url https://app.docs4.example/recovery \
  --vanity docs4-secret --password docs4-hunter2
snug link password verify --link-id <link_id> --password docs4-hunter2

Until a password is verified, resolve reports password_required and /r/{slug} answers 401. A successful verify returns a redirect_token with expires_in_seconds: 300. Pass it back as redirect_token - as a query parameter on /r/{slug}, or in the resolve body:

curl -i -H "Host: go.docs4.example" \
  "http://localhost:4000/r/docs4-secret?redirect_token=<token>"

The token is not single-use - it keeps working for the whole TTL, so treat it as a short-lived capability. The CLI's link resolve has no --redirect-token flag, so scripting the verified path means calling the HTTP endpoint directly.

Failed attempts are counted per link and per visitor_hash, defaulting to a shared anon bucket when none is sent. After 10 failures in a 300-second window that bucket answers 429 too_many_attempts, and it does so before comparing the password, so the correct password is refused too until the window rolls off. Attempts 1-10 returned 401 invalid_password, attempt 11 returned 429, and so did the right password. Because the hash is caller-supplied (see below), this bounds one claimed visitor, not one attacker - do not treat it as your only brute-force defense on a high-value link.

Cloaking

A link with security_cloaking.enabled diverts callers at suspicious or below - suspicious, known_bot, and malicious were all cloaked while neutral and trusted passed through. A campaign that sets cloak_unknown_referrers additionally cloaks any caller that sent no referrer_class. Cloaked callers get interstitial_url if one is set and 403 otherwise; the real destination is never disclosed.

quarantine is the operational version of the same thing: it flips the status, keeps the counters, and routes callers to the interstitial you pass.

Agent-injected context

A campaign can let named agents add parameters at click time without rewriting stored destinations:

{
  "name": "docs4-agentic",
  "default_domain": "go.docs4.example",
  "agent_context_policy": {
    "enabled": true,
    "allowed_agents": ["docs4-router"],
    "max_added_query_params": 2,
    "allow_tracking_pixel": true
  }
}

Resolving with agent_id=docs4-router then appends a deterministic agent_ctx=docs4-router:<link_id>:<bucket> parameter and surfaces agent_pixel_docs4-router in tracking_pixels. Injection fails closed in every other case: an agent not on allowed_agents gets nothing, and an allowed agent whose caller is suspicious or below gets nothing either, because the floor is neutral.

One sharp edge: max_added_query_params defaults to 0 and the cap is applied by truncating the injected list, so enabling the policy without setting it still reports the agent_id and still fires the pixel while injected_params comes back empty and the URL is unchanged. Set it to at least 1.

These signals are caller-supplied

reputation_bucket, country, device_family, referrer_class, visitor_hash, and agent_id all arrive as request parameters, including on the unauthenticated /r/{slug} route - anyone can append ?reputation_bucket=trusted. There is no reputation adapter behind them today. Treat conditional routes and cloaking as personalization and traffic shaping, and keep any decision that must not be forged in the destination application. Full parameter definitions are in the Link API reference.

On this page