Public paths
What a public path is#
Every other request to an app requires signing in with Google, and the person's identity arrives as x-wirl-user-id and the rest. A public path is a narrow, named exception: one path on your app — or one path prefix — that the internet may call with no sign-in at all, because the caller is not a person. Slack, Stripe, GitHub and a form on a public site all work the same way: they POST to a URL you give them, on their own schedule, and expect an answer. Before a public path could be declared, that URL returned a sign-in redirect and the whole class of integration was impossible to build.
wirl checks the sender's signature before your app is ever called, using a secret you declare but never handle in code — the same shape as any other connection.
It is not a way to make the whole app public. Visibility (private, org, or public) is a separate setting and is unaffected: a dashboard only Finance can open can still have one webhook endpoint Stripe reaches, and that endpoint gives away nothing about the rest of the app. It is also not a general-purpose ingress rule: a path is one exact string or one path ending in a single /*, at most ten per app, and only on a service app — a static bundle declaring one is refused at deploy with Public paths need a server.
Declaring one#
In wirl.json, beside the connections it names:
{
"app": "support-bot",
"server": "server.mjs",
"connections": [
{ "slug": "slack-signing", "kind": "webhook_secret" }
],
"public_paths": [
{ "path": "/slack/*", "verify": "slack", "connection": "slack-signing" }
]
}
Each entry in public_paths:
path— starts with/, and is either exact or ends in one trailing/*covering everything below it. No leading wildcard, no regex, and no bare/*— that is the whole app, which is what visibility is for. Anything under/__wirlis reserved for wirl. Required.verify—slack,stripe,github, ornone. Required.connection— thewebhook_secretconnection holding the signing secret. Required for every provider; refused if given alongsideverify: none, which checks nothing and so needs nothing to check against.
The secret itself is a connection, declared exactly where every other credential is. connections gains a webhook_secret kind: a connection that holds one secret and no hosts. The outbound broker — the thing that attaches a connection's credential to your app's own outgoing calls — refuses to hand this one out; only wirl's verifier reads it. public_paths only names the connection by its slug, so wirl.json never carries the secret itself and stays safe to commit.
Where the secret goes#
If the webhook_secret connection named in public_paths does not exist yet, deploying creates it empty, grants it to the app at once, and hands back a link to the app's Connections page — the same keyless-placeholder pattern an egress connection with no key yet uses. Whoever has that link opens it in their own browser and pastes the signing secret Slack, Stripe or GitHub gave them into the "webhook signing secret" field; it goes straight into wirl's vault and nowhere else — not through the CLI, not through an agent, not through this documentation.
Until the secret is there, the path answers every request with 503: wirl is not ready, which is a different thing from the sender being wrong, so it gets a different status than a bad signature does. The app itself is never called either way.
What your app receives#
The request arrives as sent — same method, same headers, same body — with these added:
x-wirl-public: 1
x-wirl-verified: slack (absent when verify is none)
x-wirl-app-id / x-wirl-org as on every request
x-wirl-user-id / x-wirl-user-email / x-wirl-user-name / x-wirl-role
present, but empty
The sender's IP address is in cf-connecting-ip, which cannot be forged (see The contract). A verify: none path has a ceiling you get without asking (see below): 120 requests a minute from one address, 600 a minute for the app. That stops a flood, not a patient stranger, so if a form needs something tighter (three submissions a day per person, say), keep that count yourself in env.DB, keyed on that header.
wirl dev serves a declared public path the same way, except that it checks no signature, so x-wirl-verified is never set locally.
Any HTTP method can reach a public path, because a provider's own handshake may not be a POST — Slack's URL verification challenge, for instance, arrives the same way its events do. Treat x-wirl-public: 1 as meaning only "this did not come from inside a session" and nothing more. In particular, x-wirl-user-id, x-wirl-user-email, x-wirl-user-name and x-wirl-role being present-but-empty is not the same as an anonymous or a stub user — there is no user here at all, and code that treats an empty identity as some kind of guest account is treating a webhook as a person by accident. An app must not do that: check x-wirl-verified for which provider vouched for the request, and otherwise handle it as what it is — a message from a machine, not a visit from someone signed in.
The three providers, and what each one signs#
All three sign an HMAC-SHA256 over the raw request body with the connection's secret; they differ only in what else goes into the signed message, where the result is written, and whether a timestamp is involved at all:
- slack — the header
x-slack-request-timestampandx-slack-signature(v0=<hex>). wirl signsv0:{timestamp}:{body}and compares. A timestamp more than five minutes from wirl's own clock, either direction, is refused as stale even if the signature is otherwise correct. - stripe — the header
stripe-signature, a comma-separatedt=...,v1=.... wirl signs{t}.{body}and compares againstv1. Same five-minute tolerance ont. - github — the header
x-hub-signature-256(sha256=<hex>). wirl signs the body alone — nothing else goes into the message. GitHub's scheme carries no timestamp, so a signature that was valid once stays valid forever: a captured, correctly-signed request can be replayed at any later time and wirl cannot tell. If that matters for what the handler does, defend against it yourself — GitHub's own delivery id, checked against ones you have already processed, is the usual way.
none, and why it deserves a second thought#
{ "path": "/ping", "verify": "none", "connection": null }
opens a path with no signature check and no connection at all. It exists for the cases that cannot sign (a health check some monitor polls) or where nothing happens as a result of the call, but it comes with none of the protections a provider gets. It is reachable by anyone who learns the URL. What it does have is a ceiling of its own: 120 requests a minute from one sender address (an IPv6 sender is counted by its /56, which is what one customer of an ISP is ordinarily given and can use any address of) and 600 a minute for the app, across all of its none paths together. Over either, the answer is 429 and your app is never called. These are ceilings against a flood, counted at the edge in each Cloudflare location and approximate by design, not an exact meter. The count catches up a moment after the requests do, so a sudden burst overshoots: when we flooded a path from one address, a little under three hundred requests got through in the first few seconds before refusals began, and about one in seven after that. A sender spread over many addresses and many places can still reach the app-wide number. If what the path does is expensive or must happen only so often, count it yourself as well. The Settings page marks every none row with a warning for exactly this reason. If the thing calling this path can sign its requests at all, use a real verify value instead; reach for none only when there is truly nothing to check.
Limits and refusals#
- Body cap: 1 MB. A request over it is answered with 413 before your app is called, on every path,
noneincluded. - Verify rate: 600 requests a minute per app, counted across every path that carries a real
verifyvalue together — not per path. Over it, 429. Anonepath has its own ceiling, next. - Unsigned rate: 120 requests a minute per sender address, and 600 a minute per app, on
nonepaths only, counted per Cloudflare location. Over either, 429 withRetry-After: 60, before your app is called and before the body is read. - 401 — the signature did not check out: wrong secret, a tampered or mismatched body, a stale timestamp, or a header shaped wrong to even attempt the comparison. The app is never called.
- 503 — wirl itself was not ready to answer: no secret set yet, the named connection is not granted to this app, it does not exist, it is not a
webhook_secretconnection, or the control plane could not be reached at all to check. Never the sender's fault. - 429 — over the verify rate limit, or over a
nonepath's ceiling. - 413 — the body was over the 1 MB cap.
A signature that does not check out (the 401 above) is written to the app's audit log as public.refused, naming the path, the provider and the reason, at most once a minute per app so a flood of bad requests cannot fill the log with copies of the same thing. A 429 from a none path's ceiling is counted instead, as public.throttled (When the ceiling refuses, below). The other refusals here (413, the verify rate's 429, 503) are not recorded.
A request to a public path is always treated as coming from nobody, even when the person making it is signed in to the app: it arrives with the visitor headers empty, and on a none path it counts against the ceiling like any other.
When the ceiling refuses#
The ceiling refuses at the edge, before your app is called, so a refused request is never one of your app's invocations: wirl logs, the wirl_logs tool and the app's Logs page never show it. The Logs page says so in one line, and so do wirl logs and wirl_logs when they have nothing to show. The edge counts what it refused instead and tells wirl, which records it as a public.throttled line, at most one a minute for an app:
public.throttled form-intake paths: /forms/* (sender) 270, /forms/* (app) 12 · refused: 282 · started: 2026-10-08T08:50:14Z · through: 2026-10-08T08:50:29Z
pathslists each public path that refused, aswirl.jsondeclares it (its first 256 characters),/forms/*here, never the address a sender asked for; then which ceiling refused,(sender)when one sender address (an IPv6 sender's /56) went over 120 a minute and(app)when everyone together went over 600; then how many it turned away. A line shows the first eight and says how many more there are.refusedis all of them together.startedandthroughare the first and the last of them, in UTC, at most a minute apart.
The line never names a sender or its address. If someone says they were turned away, look for their path near the time they give.
It is a count, not a meter. wirl's edge runs as separate copies in every Cloudflare location that serves a request, sometimes more than one in a location, and each copy sends at most one report a minute about an app: 15 seconds after the first refusal it counts, so that a burst is one report, and never sooner than a minute after that copy's last report about the app. wirl writes the first report about an app that reaches it in each minute as that minute's line, and nothing for any later one in the same minute, so a line counts what one copy turned away, and a flood refused by more than one copy at once is undercounted. Unless a report is held (below), its line appears between about 15 seconds and a minute after the first refusal it counts. A refusal that comes too soon after a report for its request to wait for the next is held until a request reaches that copy of the edge once that report is due, and is lost if none does before Cloudflare recycles the copy. A report that does not reach wirl, while it is being redeployed say, is not sent again. One copy counts for at most 1,000 apps at once.
Anyone who can deploy the app sees its own lines on its Logs page, under Turned away at the edge: the latest 20 from the last 7 days, newest first, and none about any other app. The organisation's Activity page, for its owners and admins, shows each app's latest line, for the 5 apps refused most recently in the last 7 days, under a heading of the same name and apart from the record itself, so that a flood of them can never push the rest of the record off the page, nor a flood on one app hide another; each links to its app's Logs page.
Where people see it#
The app's Settings page lists every public path the live deployment actually serves: its full URL, how it is checked, and — for a verified one — which connection it checks against. It is read-only there, because wirl.json is the source of truth and a deploy is the only way to change it. A none row carries the line "Anyone who knows this URL can call it."
The Connections page shows a webhook_secret connection with "Checks incoming webhooks" in place of a host list, since it has none.
What the edge refuses before your app is called is never in wirl logs or the Logs page, which show only requests your app was called for. A none path's 429s are counted as public.throttled lines, one a minute at most, on the app's Logs page, for anyone who can deploy it, and each app's latest in the organisation's Activity (see When the ceiling refuses); bad signatures are in Activity as public.refused, at most once a minute.
After a rollback#
Public paths belong to the deployment, the same way the app's runtime kind and upstream do — not to the app the way schedules do. Rolling back restores exactly the public paths the older deployment declared, replacing whatever the newer one had, so the edge's behavior for public traffic matches the code you rolled back to rather than the manifest you rolled back from.
A worked Slack app, end to end#
Say support-bot wants Slack to push events at it — a message posted, a reaction added — rather than polling for them. wirl.json:
{
"app": "support-bot",
"server": "server.mjs",
"connections": [
{ "slug": "slack-signing", "kind": "webhook_secret" }
],
"public_paths": [
{ "path": "/slack/events", "verify": "slack", "connection": "slack-signing" }
]
}
Deploy it. acme here is a company's Google Workspace domain org, where a new app defaults to org visibility — shown below — or the private one, if you deployed into a personal org:
$ wirl deploy
Deployed support-bot v1 — 3 files
https://acme--support-bot.wirl.run
Visible to everyone in the organisation. Share by email to add someone outside it.
Public: https://acme--support-bot.wirl.run/slack/events (slack)
Needs a key: slack-signing. Add it here, and the app starts working:
https://app.wirl.dev/apps/acme/support-bot/connections
A wildcard entry like /slack/* prints as written; give the provider a concrete path under it.
The Public: line is the exact address to give Slack. slack-signing did not exist before this deploy, so it is created and granted to support-bot in the same step, but with no secret in it yet, which is what Needs a key says. Open that link, and under "webhook signing secret" paste the Signing Secret from the Slack app's Basic Information page. That value goes into wirl's vault; support-bot never sees it and neither does whatever agent ran the deploy.
In the Slack app's Event Subscriptions page, paste the same URL from the Public: line into "Request URL." Slack immediately POSTs a url_verification challenge to it to confirm you control the endpoint — this is one of the requests a public path answers with no session, method and all — and once the secret is in place your handler answers it:
export default {
async fetch(request) {
const url = new URL(request.url);
if (url.pathname !== '/slack/events') return new Response('not found', { status: 404 });
// wirl already checked the signature; this header says which provider vouched for it.
if (request.headers.get('x-wirl-verified') !== 'slack') return new Response('forbidden', { status: 403 });
const body = await request.json();
if (body.type === 'url_verification') return new Response(body.challenge);
// body.event is the real payload once Slack starts sending events.
return new Response('ok');
},
};
From here, every event Slack sends arrives signed, gets checked before support-bot sees it, and shows up with x-wirl-public: 1 and x-wirl-verified: slack — while who else can open support-bot itself is exactly whatever it was before any of this was declared, unchanged either way.