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-signupHTTP/1.1 307 Temporary Redirect
location: https://app.docs4.example/signup?utm_source=newsletter&utm_medium=emailOutcomes
| Link state | /r/{slug} | resolve outcome |
|---|---|---|
| Active, routed | 307 to location | redirected |
| Password set, not yet verified | 401 | password_required |
| Cloaked, interstitial set | 307 to the interstitial | cloaked |
| Cloaked, no interstitial | 403 | cloaked |
| Quarantined | as cloaked, above | cloaked |
Draft (before starts_at) or paused | 403 | error link_inactive |
Past expires_at or over max_clicks | 410 | expired |
| Deleted link | 404 | error link_inactive |
Unknown slug, or wrong Host | 404 | - |
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_familyis an exact string match. The route above fires foriosand falls through forandroid. A rule meant for both phones needs two routes.reputation_minis a floor on the caller's bucket, orderedmalicious<known_bot<suspicious<neutral<trusted. Aneutralcaller does not satisfy atrustedfloor.referrer_classis an exact, case-sensitive match on what the caller sent:Newsletterdoes not match a rule written asnewsletter.country_allowlist, in contrast, is case-insensitive -desatisfies["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| Request | selected_route | final_url |
|---|---|---|
device_family=ios | mobile-app | docs4app://signup |
reputation=trusted, country=de | eu-trusted | https://eu.docs4.example/web?utm_source=... |
device_family=desktop | none | https://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.
Deep-link fallback
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=heroutm_source=preset survived and utm_source=linklevel was not appended.
Set skip_utm on a route to suppress appending entirely for that branch.
Password-protected links
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-hunter2Until 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.