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-role present, but empty
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 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. Because the rate limit lives on the verify step, a none path also has no rate limit of its own — it is the one genuinely unlimited surface anywhere in wirl, reachable by anyone who learns the URL, as often as they like, forever. 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 is outside this limit, per the point above. - 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 above.
- 413 — the body was over the 1 MB cap.
A refusal 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.
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.
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:
$ npx wirl deploy
Deployed support-bot v1 — 3 files
https://acme--support-bot.wirl.run
Visible to everyone in the organisation. Share by name to add someone else.
Public: https://acme--support-bot.wirl.run/slack/events (slack)
Connections: slack-signing
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 — that is Connections: slack-signing — 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.