Webhooks and public paths
A public path is a path on your app that anyone can reach without signing in or sending a token. Use one for webhooks. Stripe, GitHub and Slack can’t sign in, so you open the path they send to, and the rest of the app stays behind the edge.
Give the path a verifier that names the provider, and we check each delivery’s signature before your app sees it. The provider’s signing secret stays with us. Your app gets a token from us that names the provider, and checks it with the SDK’s requireWebhook.
Open a path for a webhook
Section titled “Open a path for a webhook”Your workspace’s publish policy may keep opening paths to the people its owners name.
-
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 few seconds 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 few seconds, 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. Your app gets no token, and has to assume a request there could come from anyone. A path opened this way has a Verify button, so you can add a verifier later.
You can change paths and verifiers only in the dashboard, so a person always makes that decision. The CLI, and an agent using your CLI session, can list public paths but can’t open one or change a verifier. A bypass token can’t list them, because it only reaches the app it was made for. If you last signed in more than 15 minutes ago, the dashboard asks you to sign in again.
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. A request there for a path you opened is answered 401 (or sent to sign in, from a browser) with the address where that path is public, so a sender pointed at the wrong one says where to point it.
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 * can only be the whole last part. A pattern can’t have . or .. parts, empty parts or percent-encoded characters, and can be up to 200 characters long.
/* on its own is refused, because a whole app can’t be public. You can’t open anything under /.jiayang, in any letter case, because we use that path on every app for sign-in. You can open /.well-known/*, 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. So we check those spellings too. A path that matches a verified pattern once you ignore case, or once you drop an extension from a verified exact pattern, is checked by that pattern’s verifier, even if it got in under another pattern.
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 don’t put the secret, or anything shaped like a key, in either.
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, we refuse a delivery signed more than 5 minutes before it arrives, and one whose signed time is more than 5 minutes ahead of our clock. You can set this from 1 to 10 minutes with How old a delivery may be, in the menu on the path’s row.
Point one provider endpoint at each path. Two endpoints at the same provider sign with two different secrets, and a verifier holds one secret, or two while you rotate it.
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.
Parse the body as you normally would. Nothing needs the 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. 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 our tokens, as below. The edge strips every X-Jiayang-* header the sender set and adds none of its own, so the SDKs’ requireUser() refuses the request. Don’t call it in your webhook handler.
Take an app that calls requireUser() on every path, like the one in the SDK overview. 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 hello app from your first deploy doesn’t call requireUser(), so it answers 200 with Hello, there. instead. It reads X-Jiayang-Email, and the edge didn’t set one. Either way, the log shows the edge let the request through with no caller:
20:55:11 ALLOW - hello POST /acme/hello/hooks/stripe -Route the webhook path to a handler that skips requireUser() and checks the provider’s signature itself, using a signing secret your app has to hold. With a verifier, you need neither.
On either kind of path, the edge removes cookies from the request and Set-Cookie from the response, as it does for tokens.
It also removes Sec-Fetch-Mode and Sec-Fetch-Dest. A request with no credential isn’t a browser loading one of your pages, and on an app that ships files with a single-page index.html, Cloudflare answers a request that says it is with that index.html before your code sees it. Without the two headers, a path with no file reaches your code.
A request with a bypass token or CLI session
Section titled “A request with a bypass token or CLI session”A request with a bypass token or a CLI session in Authorization is treated as coming from that caller, on a public path as anywhere else. The edge checks the token as it would on any path.
If the token can use the app, the request arrives with that caller’s identity token, and a verified path’s signature check doesn’t apply. If it can’t, 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 one of ours reaches your app untouched.
On a static site
Section titled “On a static site”A static site has no code to answer for itself, so we answer for it. On a public path it serves only real files under the pattern, read with GET or HEAD. Anything else answers 404 with an empty body:
- a missing file, even though the site serves its
index.htmlor404.htmlfor one when you’re signed in - a path that only a
_redirectsrule answers, with no file of its own - a file whose
_redirectsrule sends it, or serves something else in its place, from outside the pattern - a directory with no
index.htmlof its own
With /hooks/* open, /hooks/ping.txt is served if the site has that file, and /hooks/nope.txt is a 404. Signed in, /hooks/nope.txt still gets the site’s index.html, as it always did. A redirect between spellings of one file, like /hooks/page.html to /hooks/page, still works while it stays under the pattern.
A file whose name has @, =, + or $ in it can’t be reached on a public path: the site sends it to an escaped spelling, which a public path doesn’t take.
For up to a minute after an app with code becomes a static site, its public paths can still answer as they did for the code, until the edge picks up the change. A missing file there can get the site’s index.html in that minute. If that page is private, close the app’s public paths before you deploy the site, and open them again once it’s live.
The public paths on a static site serve up to 1,000 files between them. Opening a path that would cover more, or deploying a site with more under the paths already open, is refused with a message saying which path. Open a narrower one.
On an app that ships files
Section titled “On an app that ships files”A framework build often ships files beside its code. On a public path, a request for one of those files under the pattern gets the file, and anything else goes to your code.
Three things would answer some of those requests before your code does, so we don’t allow them while the path is open:
- a
_redirectsrule for a path under the pattern run_worker_firstset to a list of paths that leaves the pattern out, withnot_found_handlingset tosingle-page-applicationor404-page. A path there with no file would get yourindex.htmlor404.html- a page beside the pattern. With
/hooks/*open and a/hooks.htmlbut no/hooks/index.html, a request for/hooks/is redirected to/hooks, which tells anyone that page is there. The same goes for/status/index.htmlwith/statusopen and no/status.html
Opening such a path is refused, and so is deploying or rolling back to a version like that while the path is open. The message names the rule, the page or the path. Move the redirect into your code, add the path to run_worker_first, set not_found_handling to none, or rename the page.
An app that was already live like that when these checks came in had the path closed for it. The workspace’s owners got an email naming the path and why, and the activity log shows it. Fix the version, then open the path again from the app’s Sharing tab.
A path with a verifier stays open, since it only takes a signed delivery. If you remove the verifier from a path on an app that ships files, the app’s paths with no verifier ask for a sign-in for a minute or two while we check them, and one the files would answer first is closed.
Checks to keep in your app
Section titled “Checks to keep in your app”We refuse a delivery whose signature doesn’t match. Keep requireWebhook() in the route anyway. Without it, the route takes unchecked requests:
- for up to a minute after you add a verifier
- from the moment you remove one
- on any path where a more specific pattern with no verifier wins
Make a repeated delivery harmless:
- On Stripe, Slack and Standard Webhooks, we refuse a copy of a delivery your app answered with a 2xx. A delivery your app answered with anything else can arrive again within the time allowed, so skip a
deliveryyou’ve already handled. - GitHub, Shopify and plain HMAC senders don’t sign a time. Anyone with a copy of a delivery can send it again at any time, and change the query string or any header except the signature. Act on what the signed body says, never on
X-GitHub-Event,X-Shopify-Topicor another header, and make sure handling a delivery twice is 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.
Test a webhook
Section titled “Test a webhook”On your machine, jiayang dev --as webhook --provider stripe --pattern '/hooks/*' sends every request under /hooks/ to your app as a Stripe delivery, so you can work on what the route does with one. It checks no provider’s signature, since no provider sent one. To test that part, deploy, send a test delivery from the provider, and watch it arrive.
jiayang watch acme/hello20:58:02 ALLOW webhook:stripe hello 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.
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 None, stop now isn’t offered for Shopify.
-
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.
The row’s menu also has:
- 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. 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 time allowed, 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 paths without signing in:/hooks/github verified: GitHub, secret set 2026-09-20 by ana@example.com, last delivery 2026-09-23 20:40 UTC refused because the signature didn't match the secret https://jiayang-apps.cloud/acme/hello/hooks/github/hooks/other not verified, opened by ana@example.com https://jiayang-apps.cloud/acme/hello/hooks/other/hooks/stripe verified: Stripe, secret set 2026-09-23 by ana@example.com, previous secret accepted until 2026-09-24 20:58 UTC, last delivery 2026-09-23 20:58 UTC let through https://jiayang-apps.cloud/acme/hello/hooks/stripe
Send requests to the address under each path. The app's own address asks everyone to sign in, public paths included.Under each path is the address to send to. A prefix such as /hooks/* shows https://jiayang-apps.cloud/acme/hello/hooks/, with the rest of the path to go on the end. A verified path shows its provider, who set its secret and when, how long a previous secret is still accepted, and whether the latest delivery in the last 30 days got through. Only people who can change the verifier see which check failed or which secret matched: workspace owners and admins, and app owners. The secret itself is never shown. --json gives the whole record, and an agent’s list_public_paths gives 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, and --allow-sensitive-name doesn’t override that. A name with WEBHOOK in it that reads like a credential gets the same advice, but --allow-sensitive-name sets it if the app has to hold it.
Close a path
Section titled “Close a path”In Sharing, select the remove button next to the path. For a verified path you’re asked to confirm, because closing it also removes its verifier and secret. Select Close it. Within a minute the path answers 401 again, and any response still streaming from it is cut off.
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 we couldn’t get the list of apps, one client made us look up more than 600 names in a minute. |
| 429 | too many requests |
One client made us look up the same app more than 600 times in a minute. This only happens while the app’s workspace can’t serve requests, or we can’t look the app up. It applies 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, whether it’s written out or as %2e. It’s resolved before the edge reads the path, as any URL parser does, and your app gets the resolved path, so the edge and your app see the same one. /acme/hello/hooks/./stripe and /acme/hello/hooks/%2e/stripe both reach /hooks/stripe. A .. that climbs out of an open pattern lands on the path it names, and that path has to be open itself.
The 600-a-minute limit
Section titled “The 600-a-minute limit”A provider sends for all its customers from a few shared addresses, so someone else’s endpoint at the same provider, or any Worker, can use up the count on your open path. Past 600 in a minute, your own deliveries from that address get 429 until the minute is up.
If the sender signs its deliveries, give the path a verifier. Deliveries to a verified path don’t count toward this limit. They have their own limits, described below.
How the limit counts, and how the edge looks an app up
The limit counts per client IP address, app and open pattern, at each Cloudflare location. Everything under /hooks/* shares one count. Requests to paths the app hasn’t opened share a separate count, so someone probing for paths doesn’t use up an open path’s limit. What the same address sends to other apps doesn’t count toward yours.
To know which count a request goes toward, and whether its path is verified, the edge first looks the app up and caches the answer for up to a minute. These lookups are limited too, so nobody can force a lookup with every request by naming a made-up app.
Each Cloudflare location keeps a list of the names that have an app, refreshed every minute or so. Looking up a name on the list counts only toward that app, 600 a minute per client IP address. Because the answer is cached, a sender delivering to your app doesn’t come close to this limit. Looking up a name that isn’t on the list counts toward a separate 600 a minute per address. Past that, those names get the answer for an app that doesn’t exist, without a lookup.
Refusals and failed lookups aren’t cached. While your workspace can’t serve requests, or we can’t look your app up, every request is a lookup, and past 600 from one address, requests get 429 until the minute is up. The same goes for a location that can’t get the list. 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 within its 600.
Extra checks on a verified path
Section titled “Extra checks on a verified path”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 |
We couldn’t check it in time. Providers send it again. |
Every 401 says 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 with a valid signature. 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 us, and your app never saw the request. The log says why:
20:58:40 DENY - hello 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. If your app answers with anything but a 2xx, the provider also sees a failed delivery. We don’t count it as already delivered, so the provider’s retry gets through. The one exception is a path under a flood, described below, where a copy sent within a minute is refused.
Limits on verified deliveries
Section titled “Limits on verified deliveries”Verified deliveries count toward the workspace’s requests like any other. A workspace can take bursts of 600 verified deliveries, then 10 a second. The busiest verified path gets at most half of a burst, 300, so a flood at one path gets 429 for that path’s deliveries and leaves room for the others. A copy of a delivery that already got in doesn’t count again.
While a flood of forged deliveries hits a path, that path’s real deliveries can get 503. Most providers retry them. GitHub doesn’t, so redeliver those from the webhook’s Recent Deliveries.
Why one path can’t take it all, and what counts as a flood
A delivery can pass verification and still not be meant for you, because anyone can install your GitHub App or Slack app and have the provider sign their own events to your path. So half of a burst is kept for paths that have had fewer than half as many deliveries in the last minute or so.
Nothing sent to a verified path counts against the address it came from. Providers send for all their customers from a few shared addresses, so limiting the address would turn away your real deliveries along with anyone else’s. Each delivery is judged on its own.
A path is under a flood once it has refused about 60 deliveries in a minute, and more than it let through, so an occasional forged delivery doesn’t trigger it. A copy of a delivery it let through in the last minute counts as a refusal. The copy still gets through, unless the path is under a flood. Then it’s refused as soon as its signature header is read.
Before answering 503, a delivery waits up to a second and a half for a free check, so a short burst turns nothing away. Paths under a flood share half of the checks, and one workspace’s flooded paths can use only one of those at a time. Outside a flood, one workspace’s paths can use only a few checks at once, so no single customer can take them all.
Limits
Section titled “Limits”An app can have 3 public paths on Free, and 10 on Team and Business. Ten is the most on any plan, because the edge keeps each app’s open paths in memory between requests and more wouldn’t fit. Going past it in the dashboard shows:
That's 4 public paths, and the Free plan allows 3 per app. 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 who made it and every path open after 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 hello POST /acme/hello/hooks/stripe -20:58:40 DENY - hello 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 we keep
Section titled “What we keep”On a verified path, the edge sends the delivery’s body and its signature headers to our control plane, which checks them and keeps neither. What we keep:
- 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 an hour.
- The signing secret, encrypted. We decrypt it only to check a delivery to its path, and delete it when you remove the verifier or close the path.
Next steps
Section titled “Next steps”- Tokens for scripts and machines: when the caller can hold a credential, give it a token instead.
- Share an app
- SDK overview
- Security