You send paid or partner traffic through a redirect and you want every click to arrive at the destination carrying its own identifier — something to join against a conversion later. The obvious tool is a Worker, and for anything that needs to record the click, a Worker is still the only primitive that can. But if all you need is for each click to carry a unique id, there is a way to get one on the free plan, inside a plain Single Redirect, with no code in the request path at all.
We tested it on this site’s zone on 30 July 2026. It works, and three of its edges bite. Here is the rule, the measurements, and the traps.
The function that is banned, and the field that is not
The ruleset expression language has uuidv4(). You cannot use it here. The documentation
restricts it to rewrite expressions of Transform Rules, and the API enforces that at
validation time. This is the live answer to an attempt to put it in a redirect target:
"'concat(\"https://301.sh/?click_id=\", uuidv4(cf.random_seed))' is not a valid
value for target_url because the use of field cf.random_seed is not allowed,
the use of function uuidv4 is not allowed" (code 20083)
Both the function and its seed field are named as forbidden. So the expression language
cannot mint randomness in a redirect. What it can do is reuse an identifier Cloudflare has
already minted for the request: cf.ray_id, the ray ID that every request through
Cloudflare gets, the same value the CF-RAY response header and the dashboard logs show.
Its field documentation carries no product restriction, and the validator accepts it in a
redirect target.
One rule, measured
A dynamic Single Redirect. The match is ordinary; the target is an expression instead of a static URL:
Expression: (http.host eq "301.sh" and http.request.uri.path eq "/go")
Target URL: concat("https://destination.example/?click_id=", cf.ray_id)
Status: 302, preserve query string off
Five requests in a row through that rule, on this zone:
Location: https://301.sh/…/?click_id=a23684c8a885b183-BTS CF-RAY: a23684c8a885b183-BTS
Location: https://301.sh/…/?click_id=a23684cbfd5a2915-BTS CF-RAY: a23684cbfd5a2915-BTS
Location: https://301.sh/…/?click_id=a23684cf493d696b-BTS CF-RAY: a23684cf493d696b-BTS
Three properties, all visible in the output. Every request got a different id. Each id is
identical, byte for byte, to the CF-RAY header of the same response — which means the
click id in your destination’s logs joins directly against Cloudflare’s own logs and
dashboard. And the substituted value is the full form with the data center suffix
(-BTS here), not just the sixteen hex characters, so parse accordingly.
The preserve_query_string trap
The obvious next step is to keep the campaign parameters the click arrived with. The rule has a switch for that, and combining it with a target expression that already carries a query is where it breaks — measured, not guessed:
| Setup | Incoming request | Location actually sent |
|---|---|---|
preserve_query_string: true |
/go?utm_source=tg&gclid=abc123 |
…/?utm_source=tg&gclid=abc123 — click_id gone |
preserve_query_string: true |
/go |
…/ — no query at all, even the target’s own |
| manual merge, flag off | /go?utm_source=tg&gclid=abc123 |
…/?click_id=a2368ff0…-BTS&utm_source=tg&gclid=abc123 |
preserve_query_string does not merge the incoming query into your target. It replaces the
target’s query entirely — including when the incoming query is empty, which deletes the
click_id you just built. The two features are mutually exclusive.
The working form leaves the switch off and does the merge in the expression:
concat("https://destination.example/?click_id=", cf.ray_id, "&", http.request.uri.query)
One cosmetic edge: when the incoming query is empty, the result ends in a bare &. Every
query parser we care about ignores it, but it is in the URL, so know it is there before you
diff logs.
A 301 will replay yesterday’s id
The same rule with status 301 substitutes a fresh ray ID per request at the edge — we
measured that too. The problem is in front of the edge: the 301 comes back with no
Cache-Control header, and a permanent redirect without explicit freshness information is
exactly what browsers are allowed to cache heuristically. A repeat visitor can then be
redirected by their own cache, carrying the click id from their first visit, and the request
never reaches Cloudflare at all — a duplicate id in your logs and an undercounted click.
For this pattern the status you want is 302 or 307.
Where it breaks
Nothing on Cloudflare’s side records the id. Redirect is a terminating action in the
first request phase; no analytics product ever sees the request,
and the rule cannot write anywhere. The id exists only in the Location URL. If the
destination does not log its query string, the id evaporates in flight.
A ray ID is an identifier, not a secret. It is unique, but it is not random in any adversarial sense — do not use it as a token that authorizes anything. Join key: yes. Capability: no.
Every fetch gets an id, not every human. Link prefetchers, scanners and preview bots follow redirects too, and each gets its own perfectly valid click id. The rule cannot tell them apart; dedup and filtering stay the destination’s job.
The quota is real but roomy. Ten Single Redirect rules per zone on the free plan — per zone, not per account — then 25 on Pro, 50 on Business, 300 on Enterprise (checked 30 July 2026). One tracking rule with a wildcard covers a whole path family, so ten goes further than it sounds. Regular expressions need Business; this pattern does not need them.
When you need more than a stamp
If the destination is yours, this rule plus your own analytics reading click_id may be the
whole system: no Worker, no code, nothing to maintain, and the id joins against Cloudflare’s
logs by construction.
The pattern stops being enough exactly where the attribution article starts: the destination is not yours, or you need the click recorded even when the destination logs nothing, or you run this across a portfolio of domains rather than one zone. Recording at the redirect side is precisely the thing a terminating rule cannot do — that side needs to be operated, which is what 301.st is for: it owns the redirect end, mints and records the id there, and hands the same id downstream, so the join works whether or not the destination cooperates.