Public paths and webhooks
A public path is a path on your app that anyone can reach, with no sign-in and no token. It exists for webhooks: Stripe, GitHub or Slack can’t sign in, so you open the one path they send to and leave the rest of the app behind the edge.
Give the path a verifier, the provider that signs what arrives there, and the platform checks each delivery’s signature before your app sees it. The provider’s signing secret stays with the platform, and your app never holds it. Your app gets a token the platform signed instead, saying which provider sent the delivery, and checks it with the SDK’s requireWebhook.
Open a path
Section titled “Open a path”-
Open the app in the dashboard and go to Sharing.
-
Under Public paths, type the path your provider sends to, like
/hooks/stripe. Choose the provider under Who sends to it?, and select Open it. -
For Standard Webhooks, choose whether its headers start with
webhook-orsvix-. For Other (HMAC-SHA256), give the header the signature is in, whether it’s hex or base64, and anything before it, such assha256=. The other providers need nothing here. -
Paste the provider’s signing secret under Signing secret. The field is write-only: nobody can read the secret back, and it never reaches your app. For GitHub and Other, where you choose the secret yourself, Generate one makes one. Copy it and paste it at the provider as well, because once you save, nobody can see it here again.
-
For Stripe, Slack and Standard Webhooks, choose How old a delivery may be. 5 minutes suits almost every sender.
-
Copy the address under Give Stripe this address, or whichever provider you chose, into the provider’s webhook settings. Then select Open and verify.
-
Give it a minute to go live, then send a test delivery from the provider. The path’s row says whether it got through, and why not if it didn’t.
Within a minute, deliveries to that path reach your app. The path’s row then reads like this:
/hooks/stripe Verified · Stripehttps://jiayang-apps.cloud/acme/hello/hooks/stripe · opened 3m ago by ana@example.comSecret set 3m ago by ana@example.com. A delivery signed more than 5 min before it arrives is refused.Last delivery 12s ago: let through.How the app checks it, on the row, shows the call for each SDK.
Choosing Nobody I can verify opens the path with no check at all: your app then gets no token and has to treat every request there as a stranger’s. A path opened that way has a Verify button, which opens the same dialog to add a verifier later.
You open paths and change verifiers in the dashboard only. The CLI, or an agent working with your CLI session, can list them but can’t open one or change its verifier, so nothing but a person can put an app on the open internet or decide which signatures let a stranger in. A bypass token can’t even list them: it only reaches the app it was made for. The dashboard also asks you to sign in again when your last sign-in was more than 15 minutes ago.
App owners, and the workspace’s owners and admins, can open and close paths and change their verifiers.
The address
Section titled “The address”A public path is reached through the apps domain, with the workspace and the app in front:
https://jiayang-apps.cloud/acme/hello/hooks/stripeYour app sees the request at /hooks/stripe. The app’s own address, https://hello--acme.jiayang-apps.cloud, still asks everyone to sign in, public paths included, so a webhook sent there never arrives.
Patterns
Section titled “Patterns”| Pattern | Matches |
|---|---|
/hooks/stripe |
Exactly /hooks/stripe. |
/hooks/* |
Every path under /hooks/, like /hooks/stripe or /hooks/github/push. Not /hooks itself. |
A pattern is one or more parts separated by /. Each part uses letters, digits and . _ ~ @ = + $ -. A * may only be the whole last part. Patterns have no . or .. parts, no empty parts, nothing percent-encoded, and at most 200 characters. /* on its own isn’t allowed: a whole app is never public. Nothing under /.jiayang, in any case, can be opened: that path is the platform’s on every app, where sign-in lands. /.well-known/* is yours to open, for an ACME challenge or an app-site association file.
Anything else is refused, with what’s wrong with it:
/hooks/*/x has a * that isn't at the end. A * can only end a path, after a slash, as in /hooks/*.The whole app can't be public. Open one path, like /hooks/stripe, or everything under one, like /hooks/*.When a path matches two open patterns, the more specific one decides, and so does its verifier: an exact pattern beats a prefix, and a longer prefix beats a shorter one. With /hooks/* verified for GitHub and /hooks/stripe verified for Stripe, a delivery to /hooks/stripe is checked as Stripe’s.
A verified exact pattern also covers its spelling with a trailing slash, /hooks/stripe/. An exact pattern with no verifier opens that one path and not its trailing-slash spelling. Some frameworks, Express among them, ignore case when they match a route, so /hooks/STRIPE can reach your /hooks/stripe handler. Rails, and older versions of Spring, send /hooks/stripe.json there too, reading .json as the format. A path that gets in under one pattern but matches a verified one when case is ignored, or adds an extension to a verified exact one, is checked by that verifier. With /hooks/* open and /hooks/stripe verified, /hooks/Stripe, /hooks/STRIPE/ and /hooks/stripe.json are checked as Stripe’s. Neither case nor an extension opens a path: with only /hooks/stripe open, /Hooks/Stripe and /hooks/stripe.json stay shut.
Providers
Section titled “Providers”| Provider | Headers the check reads | Signed id (delivery) |
Signed time |
|---|---|---|---|
| Stripe | Stripe-Signature |
the event id | yes |
| GitHub | X-Hub-Signature-256 |
none | no |
| Slack | X-Slack-Signature and X-Slack-Request-Timestamp |
event_id, for Events API deliveries |
yes |
| Shopify | X-Shopify-Hmac-Sha256 |
none | no |
| Standard Webhooks | webhook-id, webhook-timestamp and webhook-signature, or the same three starting svix- |
the message id | yes |
| HMAC-SHA256 | the one you name | none | no |
Each needs its signing secret, and the dialog checks it looks right before you save:
| Provider | Where the secret is | What it looks like |
|---|---|---|
| Stripe | Workbench, Webhooks, your endpoint, Reveal secret | starts with whsec_ |
| GitHub | the webhook’s Secret field, which you choose | 16 to 512 characters |
| Slack | your app’s settings, Basic Information, Signing Secret | 16 to 512 characters |
| Shopify | the app’s client secret, or the key under Settings, Notifications, Webhooks | 16 to 512 characters |
| Standard Webhooks | the endpoint’s secret, on the sender’s page for it | whsec_ and then base64 of 24 to 64 bytes |
| HMAC-SHA256 | whatever the sender signs with | 16 to 512 characters |
The 16 to 512 characters are letters, digits, spaces and punctuation. Spaces and line breaks a paste picks up at either end are dropped.
Standard Webhooks covers Svix and the senders built on it, Resend among them. Those send svix-id, svix-timestamp and svix-signature; the spec’s own names are webhook-id and the rest. The dialog asks which. For any other sender, choose Other (HMAC-SHA256) and say which header holds the signature, whether it’s hex or base64, and what comes before it, such as sha256=. It has to be HMAC-SHA256 over the raw body. Everyone who can see the app can read the header name and the prefix, so neither may hold the secret or anything shaped like a key.
Shopify deletes a subscription made through its Admin API after 8 failed deliveries in a row, so send a test delivery a minute after the verifier is saved, once it’s live.
For a provider that signs a time, a delivery signed more than 5 minutes before it arrives is refused, and so is one whose signed time is more than 5 minutes ahead of the platform’s clock. That can be set from 1 to 10 minutes, with How old a delivery may be in the menu on the path’s row.
One endpoint per path. Two endpoints at the same provider sign with two secrets, and a verifier holds one, or two while you rotate.
What reaches your app
Section titled “What reaches your app”On a verified path
Section titled “On a verified path”The delivery arrives as the provider sent it: the method, the path after /acme/hello, the query string, the headers and the body, byte for byte. With it comes an identity token of kind webhook in X-Jiayang-Identity. Check it with the SDK and answer 401 when it refuses:
import { requireWebhook, Unauthorized } from "@jiayang-cloud/sdk";
try { const hook = await requireWebhook(request, env, { provider: "stripe" }); // hook.delivery is Stripe's event id} catch (err) { if (err instanceof Unauthorized) return err.toResponse(); // 401 throw err;}from jiayang import Unauthorized, require_webhook
try: hook = require_webhook(request.headers, provider="stripe")except Unauthorized: return "unauthorized", 401hook, err := jiayang.RequireWebhook(r, jiayang.ProviderStripe)if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) return}use jiayang::Provider;
let hook = match verifier.require_webhook(&headers, &[Provider::Stripe]).await { Ok(hook) => hook, Err(_) => return StatusCode::UNAUTHORIZED.into_response(),};Name only the providers that send to that route. A delivery from any other is refused, so a Stripe route can’t be handed a GitHub delivery after a pattern is widened. requireUser refuses a webhook’s token and requireWebhook refuses a person’s, so mount the route where app-wide user checks never see it: before app.use(requireUser()) in Express, outside jiayang.Middleware in Go, and in a router of its own in axum. Each language’s page shows its frameworks: Node.js, Python, Go and Rust.
The body can be parsed the usual way. Nothing needs its raw bytes.
The token holds:
| Claim | Value |
|---|---|
kind |
webhook |
iss |
The edge, https://edge.jiayang.cloud |
aud |
webhook:<app id>, which no check for a person accepts |
sub |
webhook:<verifier id> |
provider |
stripe, github, slack, shopify, standard_webhooks or hmac_sha256 |
pattern |
The public path it came in on, such as /hooks/stripe or /hooks/* |
delivery |
The provider’s signed id, where there is one |
signed_at |
The signed time in unix seconds, where the provider signs one |
wid |
The app’s workspace id |
iat, exp |
When it was made, and 60 seconds later |
jti |
A random id for this token |
It has no email and no role. A webhook isn’t a person. Its header’s typ is webhook+jwt, where a person’s token has JWT. It’s signed with the same key as a person’s token, so the same key set checks it.
On a path with no verifier
Section titled “On a path with no verifier”The request arrives as the sender made it: the method, the path after /acme/hello, the query string, the body, and headers like Stripe-Signature. If the sender puts its own credential in Authorization, that passes through too.
It arrives with no identity token, unless it carries one of the platform’s tokens (below). The edge strips every X-Jiayang-* header the sender set and adds none of its own, so the SDKs’ requireUser() refuses the request. That’s correct for the rest of your app, and it means your webhook handler must not call it.
The hello example calls requireUser() on every path, so once /hooks/stripe is open with no verifier, the edge lets the request through and the app itself refuses it:
curl -X POST https://jiayang-apps.cloud/acme/hello/hooks/stripe -H 'Stripe-Signature: t=1,v1=abc' -d '{}'unauthorizedThe status is 401, and it came from the app. The log shows the edge’s side: an allow with no caller.
20:55:11 ALLOW - POST /acme/hello/hooks/stripe -Route the webhook path to a handler that skips requireUser() and checks the provider’s signature itself, with a secret it has to hold. A verifier saves you both.
On either kind of path, the edge removes cookies from the request and Set-Cookie from the response, as it does for tokens.
A request with a platform token
Section titled “A request with a platform token”A request with a bypass token or a CLI session in Authorization isn’t a stranger’s, on a public path or anywhere else. The edge checks the token as it would on any path. If the token may use the app, the request arrives with that caller’s identity token, and a verified path’s signature check doesn’t apply. If it may not, the request is refused, even on an open path: a bypass token for another app gets 403, and a person gets 403 or 404 as they would anywhere. Only a credential that isn’t the platform’s passes through to your app untouched.
What your app still does
Section titled “What your app still does”The platform refuses a delivery whose signature doesn’t hold. Keep requireWebhook() in the route all the same. Without it, the route takes unchecked requests for up to a minute after a verifier is added, for good once one is removed, and on any path that a more specific pattern with no verifier decides.
Make a repeated delivery harmless:
- On Stripe, Slack and Standard Webhooks the platform refuses a copy of a delivery your app answered with a 2xx. A delivery it answered with anything else can arrive again within the tolerance, so skip a
deliveryyou’ve already handled. - GitHub, Shopify and plain HMAC senders sign no time. Anyone holding a copy of a delivery can send it again at any time, with the query string and any header but the signature changed. Act on what the signed body says, never on
X-GitHub-Event,X-Shopify-Topicor another header, and make handling a delivery twice safe. - A
Stripe-Signatureheader sent twice, where the second has not=, reads as one header with two signatures. Every other provider’s header sent twice is refused.
Try it
Section titled “Try it”jiayang dev makes tokens for people only, so on your machine a route that calls requireWebhook refuses every request with invalid identity token. Try the route on the platform instead: deploy, send a test delivery from the provider, and watch it arrive.
jiayang watch acme/hello20:58:02 ALLOW webhook:stripe POST /acme/hello/hooks/stripe -jiayang watch is for the workspace’s owners and admins. Anyone who can see the app can check the path’s row, or jiayang app public-paths acme/hello.
When a request is refused
Section titled “When a request is refused”On every public path:
| Status | Body | Why |
|---|---|---|
| 401 | unauthorized |
The path isn’t open. An app that doesn’t exist, or has nothing deployed, gets the same answer. |
| 401 | unauthorized |
The request path has a percent-encoded character or an empty part like //. |
| 401 | unauthorized |
It asks to open a WebSocket. Public paths take plain requests only. |
| 429 | too many requests |
One client sent more than 600 requests in a minute under the same open pattern, or to paths the app hasn’t opened. A verified path isn’t held to this. |
| 429 | too many requests |
While the platform couldn’t get its list of apps, one client had it look up more than 600 names in a minute. |
| 429 | too many requests |
One client had the platform look up the same app more than 600 times in a minute. That only happens while the app’s workspace can’t serve requests, or the platform can’t look it up. This one holds on a verified path too. |
| 402 or 403 | A page | The workspace can’t serve requests right now: see Limits. |
A . or .. part isn’t refused, spelled out or as %2e. It’s resolved before the edge reads the path, as any URL parser does, and your app is given the resolved path, so the two of you never disagree about it: /acme/hello/hooks/./stripe and /acme/hello/hooks/%2e/stripe both reach /hooks/stripe, and a .. that climbs out of an open pattern lands on the path it names, which has to be open itself.
The 600-a-minute limit is per client IP address, app and open pattern, counted at each Cloudflare location, so everything under /hooks/* counts toward one minute. Requests to paths the app hasn’t opened are counted together, apart from the open ones, so someone probing for paths doesn’t use up an open path’s minute. What the same address sends other apps doesn’t count toward yours.
A provider sending only your deliveries stays well inside it. But providers send for all their customers from a few shared addresses, and requests from Workers share addresses too. Someone else’s endpoint at the same provider, or any Worker, sending to the same open path counts toward the same minute, and past 600 your own deliveries from that address are answered 429 until the minute is up. If the sender signs its deliveries, give the path a verifier: deliveries to a verified path aren’t counted toward it. The checks that verify them are shared out per path and per workspace instead, as below.
To know which minute a request counts toward, and whether its path is verified, the platform first looks the app up, and keeps the answer for up to a minute. Without a limit there, someone could name a made-up app with every request and have every one looked up.
So each Cloudflare location keeps a list of the names that have an app, refreshed every minute or so. Looking up a name on it counts only toward that app, 600 a minute per client IP address, so what someone else sends from the same address to names with no app never holds up your deliveries. The answer is kept, so a sender delivering to your app never gets near that.
Only a refusal and a failed lookup aren’t kept: while your workspace can’t serve requests, or the platform can’t look your app up, every request is a lookup, and past 600 from one address it’s answered 429 until the minute is up.
Looking up a name that isn’t on the list counts toward another 600 a minute per client IP address. Past that, such a name gets the answer an app that isn’t there gets, without a lookup, and that answer is kept like any other.
An app made in the last few minutes may not be on the list yet. Until it is, it’s looked up only while the address is inside its 600 and no answer for its name is kept, and once it’s on the list, within a few minutes, it’s looked up like any other app.
If a location can’t get the list, every lookup counts toward the 600, and past it the answer is 429 until the minute is up.
A verified path also refuses:
| Status | Body | Why |
|---|---|---|
| 401 | unauthorized |
The method is anything but POST, or a header or _method in the query string asks for another. |
| 401 | unauthorized |
A signature header is missing, over 4 KiB, or not what the provider sends. |
| 401 | unauthorized |
It was signed further from now than the tolerance allows, either way. |
| 401 | unauthorized |
No signature matches. |
| 401 | unauthorized |
It’s a copy of a delivery already let through. |
| 401 | unauthorized |
The verifier, its secret or the app’s public paths changed while it was being checked. |
| 401 | unauthorized |
The path after /acme/hello is over 2,048 characters. |
| 429 | too many requests |
The workspace’s verified deliveries are over their limit, or this path has had more than its share of them. |
| 413 | payload too large |
The body is over 1 MiB. |
| 503 | service unavailable |
The platform couldn’t check it in time. Providers send it again. |
Every 401 reads unauthorized, so a stranger learns nothing about which check failed. The audit log says which. On a verified path the 402 or 403 page, and the 429 for the workspace’s limit, go only to a delivery whose signature holds: a stranger gets 401 whatever state the workspace is in.
A request with no signature, sent to a path verified for Stripe:
curl -X POST https://jiayang-apps.cloud/acme/hello/hooks/stripe -d '{}'unauthorizedThis time the 401 came from the platform, and your app never saw the request. The log says why:
20:58:40 DENY - POST /acme/hello/hooks/stripe 401 webhook_refused (missing_header)The path’s row in the dashboard says the same in words, to those who can change the verifier:
Last delivery 5s ago: refused, it had no signature header.The provider’s own log shows a failed delivery with status 401 and nothing more. A delivery your app answers with anything but a 2xx is a failed delivery to the provider too, and the platform doesn’t hold it as a copy, so the provider’s retry gets through. The one exception is a path under a flood, below, where a copy sent within a minute is refused.
Requests to public paths don’t count towards the workspace’s monthly requests, verified deliveries included. The rate limits are what bound them instead. A workspace can take bursts of 600 verified deliveries, and 10 a second after that. A copy of a delivery that already got in doesn’t count again, so nobody can use up the workspace’s share by replaying one.
No one path can use all of it. A delivery can verify and still not be yours: anyone can install your GitHub App or Slack app and have the provider sign their own events to your path. So the busiest verified path gets the top half of a burst, 300, and the other half is kept for paths that have had fewer than half as many deliveries in the last minute or so. A flood at one path, however it’s signed, gets that path’s deliveries answered 429 and leaves the others room.
Nothing sent to a verified path counts against the address it came from, whether it gets through or not. Providers send for all their customers from a few shared addresses, and whatever one of those customers can set up, someone else can too: an endpoint of their own at the same provider, pointed at your path. Holding the address off would turn your real deliveries away with theirs, so each delivery is judged on its own.
A flood of forged deliveries at a path can get that path’s real deliveries answered 503 while it lasts. Most providers retry them; GitHub doesn’t, so redeliver those from the webhook’s Recent Deliveries. A delivery that finds every check it could use busy waits its turn for up to a second and a half before that, so a short burst turns nothing away.
Paths under a flood share half of the checks between them, and the other half is kept for everyone else’s. One workspace’s paths under a flood hold only one of those at a time between them, so a flood at one tenant’s paths still leaves checks for anyone else’s path under a flood. Outside a flood, one workspace’s paths hold only a few checks at once between them, however many it has, so no one tenant’s deliveries can take them all.
A path counts as under a flood once it has refused about 60 deliveries in a minute, and more than it let through, so a forged delivery now and then doesn’t put it there. A copy of a delivery it let through in the last minute counts as one it refused. It still gets through, unless the path is under a flood: then the copy is refused as soon as its signature header is read.
Rotate a secret
Section titled “Rotate a secret”-
On the verified path’s row, open the menu and select Rotate secret.
-
Paste the new secret. For GitHub and Other, Generate one makes one to paste at the sender too.
-
Under Keep accepting the old secret for, choose how long deliveries signed with the old one still get through: None, stop now, 1 hour, 24 hours or 7 days. Shopify can take up to an hour to start signing with a new secret, so it isn’t offered none.
-
Select Rotate.
Stripe signs with both secrets while you roll one there, so roll it in Stripe first and paste the new one here. For the others, change the secret at the sender within the time you chose. A new secret can take up to five seconds to be accepted everywhere.
Until then the row says The previous secret is accepted until and a time, with Stop accepting it to end the old secret early. A delivery the old secret let through reads let through, signed with the previous secret, so you can see when the provider has moved to the new one. Rotating again drops a previous secret that’s still being kept.
Replace verifier changes the provider, its settings and the secret at once, and keeps no old secret. Remove verifier leaves the path open with no check, and within a minute deliveries reach your app with no token, so an app using requireWebhook() refuses them. How old a delivery may be changes only the tolerance, from 1 to 10 minutes, and keeps the secret.
List the open paths
Section titled “List the open paths”jiayang app public-paths acme/helloAnyone can reach these, with no sign-in:/hooks/github verified: GitHub, secret set 2026-09-20 by ana@example.com; last delivery 2026-09-23 20:40 UTC refused (signature)/hooks/other not verified, opened by ana@example.com/hooks/stripe verified: Stripe, secret set 2026-09-23 by ana@example.com (previous accepted until 2026-09-24 20:58 UTC); last delivery 2026-09-23 20:58 UTC let throughA verified path shows its provider, when its secret was set and by whom, until when a previous secret is still accepted, and whether the newest delivery in the last 30 days got through. Which check it failed, or which secret it matched, shows only to those who can change the verifier: the workspace’s owners and admins and the app’s owners. Never the secret: the platform has no way to show it. --json gives the whole record, and an agent’s list_public_paths answers the same.
With nothing open:
Nothing on acme/hello is public. Open a path in the dashboard if a webhook needs one.jiayang env set refuses a whsec_ value and says to add a verifier instead. Nothing sets one, --allow-sensitive-name included. A name with WEBHOOK in it that reads like a credential gets the same advice, and --allow-sensitive-name sets it if the app really has to hold it.
Close a path
Section titled “Close a path”In Sharing, select the remove button next to the path. A verified path asks first, since closing it removes its verifier and secret with it: select Close it. Within a minute the path answers 401 again, and a response still streaming from it is cut off.
Limits
Section titled “Limits”An app can have 3 public paths on Free, and 10 on Team and Business. Going past it in the dashboard shows:
That's 4 public paths; the Free plan allows 3 per app, and Team allows 10 public paths per app.A /hooks/* pattern counts as one, however many providers send under it. A verifier belongs to its path, so there’s no separate limit on verifiers, and every plan can have them.
In the audit log
Section titled “In the audit log”Each change is logged as app.public_paths, with the whole list as it stood afterwards and who changed it. Each request to a path with no verifier is logged with - as the caller, allowed or refused.
A delivery a verifier let through names the provider as its caller, webhook:stripe in jiayang watch, jiayang top and jiayang audit. A refused one has - as the caller, the reason webhook_refused, or webhook_too_large for the 413, and the check that failed:
20:58:02 ALLOW webhook:stripe POST /acme/hello/hooks/stripe -20:58:40 DENY - POST /acme/hello/hooks/stripe 401 webhook_refused (signature)The check is one of method, missing_header, malformed, stale, signature, replayed, unconfigured, rate_limited or too_large, and --json has it in detail.webhook.failure. For a delivery let through, detail.webhook.matched says which secret matched, current or previous, and detail.webhook.delivery has the provider’s signed id where there is one. For the workspace’s owners and admins, See deliveries on a verified path’s row opens the app’s Activity with that path’s deliveries.
Changes to a verifier are logged as webhook.set, webhook.update, webhook.rotate, webhook.retire and webhook.remove, with the path and the provider and never the secret. See Audit log.
What the platform keeps
Section titled “What the platform keeps”On a verified path the edge sends the delivery’s body and its signature headers to the platform, which checks them and keeps neither. What stays:
- The delivery’s entry in the audit log, as above. The provider’s id for the delivery is the only thing in it read from the body or the signature headers.
- For Stripe, Slack and Standard Webhooks, a hash of the delivery’s signature, so a copy can be refused. It’s deleted within half an hour.
- The signing secret, encrypted. It’s opened only to check a delivery to its path, and goes when you remove the verifier or close the path.
Related
Section titled “Related”- Tokens for scripts and machines: when the caller can hold a credential, give it a token instead.
- Share an app
- SDK overview
- Security