A Stripe webhook on an App you already have
Getting started leaves you with an App that has one Function in it. This adds a second Function beside that one: a Stripe webhook endpoint that checks the signature on every request before it believes a word of it.
The App you have is the App this goes in. It costs no App slot, it leaves the Function already there on the version it is on, and the two answer on two addresses under the one App.
You will need a Stripe account. Everything below works in test mode, so nothing here moves money.
The names in this page
Section titled “The names in this page”The examples carry the workspace, App and Function that getting started produced. Yours will read differently, and only the App slug matters:
| Here | Where it comes from | |
|---|---|---|
| Workspace address | hello-co |
npx wawesome workspace |
| App | demo |
the app in your wawesome-function.json |
| Function already deployed | hello |
wawesome init made it |
| Function you are adding | stripe-events |
the template’s own name for it |
1. Scaffold the second Function
Section titled “1. Scaffold the second Function”A Function is a directory with a wawesome-function.json in it, so a second Function is a second
directory. Put it wherever you keep projects, beside the first one or somewhere else entirely:
mkdir stripe-events && cd stripe-events
npx wawesome init --template stripe-webhook
The template is a working endpoint with signature verification already wired with the Stripe SDK. What it is and what it defends against is on its own page.
It asks two names. Take the Function name it offers, and replace the App slug:
[wawesome] Fetching Stripe Webhook Receiver (stripe-webhook)...
Function name? (stripe-events):
(an App groups the Functions of one project, and its slug is part of the public URL)
App slug? (stripe-events): demo
[wawesome] ✅ Scaffolded 11 files from 'stripe-webhook'.
That second answer is the whole difference between this and a fresh project. The App slug defaults to the directory name, and typing the App you already have is what puts this Function inside it. Type something else and you get a second App, which on the Free plan is refused outright: one App slot is all it grants.
Then the CLI shows the address, and says where to change the part of it that is already live:
[wawesome] Your Function will answer on:
https://hello-co--demo.wawesome.app/stripe-events
Every path beneath this address reaches the Function.
'hello-co' is your workspace address. It is already live, so change it
with: wawesome workspace rename <name>
Your workspace address has been live since you deployed in getting started. You are about to hand this URL to Stripe. If you rename the workspace later, the old address redirects for 90 days, but Stripe does not follow redirects on a webhook. Update the endpoint in Stripe when you rename.
2. Deploy without the secret
Section titled “2. Deploy without the secret”The template declares one variable, and the CLI asks for it before it deploys:
[wawesome] This template needs a few values before it can run.
STRIPE_WEBHOOK_SECRET (required, stored write-only)
Signing secret for this webhook endpoint. Every request is rejected unless it carries a signature made with it.
Where to find it: Stripe Dashboard → Developers → Webhooks → your endpoint → "Signing secret" → Reveal. It starts with whsec_. The secret only exists once the endpoint does, so if you have not created it yet, leave this blank and set it after the deploy prints your URL.
STRIPE_WEBHOOK_SECRET?:
Press enter. The secret does not exist yet, and it cannot. Stripe mints it when you create the endpoint, and you create the endpoint with the URL this deploy is about to print. The CLI hands you the command to run once it does exist:
[wawesome] STRIPE_WEBHOOK_SECRET left blank. Set it with: wawesome env set STRIPE_WEBHOOK_SECRET <value> --secret
The deploy runs, and the receipt is the same one getting started ended on, with a different Function in it:
======================================================
🚀 DEPLOYED SUCCESSFULLY!
======================================================
App: demo
Function: stripe-events
Version: 1
Handler: dist/index.js
Visibility: public
URL: https://hello-co--demo.wawesome.app/stripe-events
Every path beneath this address reaches the Function.
Plan: Free. This deploy does not change your bill.
Apps: 1 / 1 slots
Storage: 0 B / 1 GiB
Usage: September 2026 (UTC)
Invocations 14 / 50k (0.0%)
Caller-facing bytes 8 KiB / 10 GiB (0.0%)
======================================================
Apps: 1 / 1 slots is the line worth reading. A Function is not what a plan counts, so the second one
went into the App you had rather than needing another. Version: 1 is this Function’s own first
version. hello is still on the version it was on, and rolling either of them back leaves the other
alone.
The endpoint is live and refusing every request, because the secret it verifies against is not set yet.
3. Create the endpoint in Stripe
Section titled “3. Create the endpoint in Stripe”In the Stripe dashboard, go to Developers → Webhooks → Add endpoint. Some accounts now label this screen Event destinations; it is the same page.
Paste the URL from the receipt as the endpoint URL:
https://hello-co--demo.wawesome.app/stripe-events
Then choose the events to send. The template’s handler already covers four of them:
checkout.session.completedpayment_intent.succeededpayment_intent.payment_failedcustomer.subscription.deleted
Anything else you select still reaches the Function and is still verified. The handler logs it as
unhandled instead of failing on an event it was not written for. Add the case in src/index.ts when
you want to act on it.
Save the endpoint. Stripe shows a Signing secret on the endpoint’s page, behind a Reveal
link. It starts with whsec_, and it belongs to this one endpoint.
4. Store the signing secret
Section titled “4. Store the signing secret”Back in the stripe-events directory:
npx wawesome env set STRIPE_WEBHOOK_SECRET whsec_2f8Kd9QxLp0RvTnA4bWmYcZs --secret
[wawesome] ✅ Set secret variable 'STRIPE_WEBHOOK_SECRET' on app 'demo'.
🔒 Secret Notice:
• Encrypted at rest using AES-256
• This value can't be viewed again after you save it — only updated or deleted
• Only decrypted at the moment your function runs — never returned by the CLI, dashboard, or API after creation.
Three parts of that command matter.
--secret is what makes the write one-way. Without it the value is stored readable, and anyone who
can run npx wawesome env list against this App reads the secret that authenticates your payment
events. With it, the listing shows the key is set and four characters of the value, and nothing
reveals the rest. Keep the flag on every later write of the same key: the flag describes the write,
not the key, so setting it again without the flag stores the new value readable.
The variable lands on the App, not on the Function. hello can read process.env.STRIPE_WEBHOOK_SECRET
too, because variables belong to the App. That is what let you set it from here and have
it apply, and it is also the reason to keep unrelated projects in separate Apps.
No redeploy follows. The platform reads variables when a Function runs, so the next request Stripe sends carries the new value. Nothing was rebuilt and no version was made.
5. Send a test event
Section titled “5. Send a test event”Open a second terminal and follow the Function:
npx wawesome logs stripe-events --follow
[wawesome] 📡 Following function demo/stripe-events (Ctrl-C to stop)...
It waits there until something invokes the Function. In the Stripe dashboard, on the endpoint you just
created, use Send test webhook and pick payment_intent.succeeded. The lines arrive:
Log: Verified payment_intent.succeeded (evt_3PqX8s2eZvKYlo2C1gLkQmXz)
Log: Payment succeeded: 20.00 USD
The first line is the verification passing: the signature on those exact bytes matched the secret you just stored. The second is the handler acting on the event.
Stripe’s own view agrees. The delivery attempt on the endpoint’s page shows 200 and the body the
handler returned:
{"received": true}
That is the round trip. A signed request left Stripe, the signature checked out against a secret nothing can read back, and the response Stripe got was a 2xx, which is the only answer that stops it retrying.
6. What a rejected request looks like
Section titled “6. What a rejected request looks like”The URL is public, so the interesting case is a request that is not from Stripe. Send one:
curl -i -X POST https://hello-co--demo.wawesome.app/stripe-events -d '{"type":"payment_intent.succeeded"}'
HTTP/2 400
content-type: application/json
x-wawesome-invocation-id: 4e91c7a2-5b38-4d6f-9a01-7c2e8f3b6d54
{"error":"Signature verification failed."}
The response names no reason, on purpose. Your logs do. Read that exact run with the id off the header:
npx wawesome logs 4e91c7a2-5b38-4d6f-9a01-7c2e8f3b6d54
{"ts":"2026-09-08T09:41:12.882091Z","stream":"stdout","msg":"Warn: Rejected webhook: missing_header — No Stripe-Signature header was sent."}
Warn: is the prefix the engine puts on a console.warn. The rest is the template’s own line, and the
word after Rejected webhook: is the check that failed. There are four:
| Reason | What happened |
|---|---|
missing_header |
The request carried no Stripe-Signature at all, which is the curl above and every bot that finds the URL |
malformed_header |
A header was there with no timestamp and v1 signature in it |
timestamp_outside_tolerance |
The signature was made more than five minutes ago, which is what a replayed request looks like |
no_matching_signature |
The signature is well formed and made with a different secret than this endpoint’s |
The last one is the one you are most likely to hit for real, and it is nearly always the wrong
whsec_: a secret copied from another endpoint, from live mode into a test-mode endpoint, or from
before somebody rotated it. Set the key again from this endpoint’s own page, with --secret, and the
next delivery verifies.
If you skip step 4 altogether, the failure reads differently. The handler answers 500 and writes:
{"ts":"2026-09-08T09:38:04.117420Z","stream":"stdout","msg":"Error: STRIPE_WEBHOOK_SECRET is not set — run: wawesome env set STRIPE_WEBHOOK_SECRET whsec_... --secret"}
Without the secret the endpoint can verify nothing, so it refuses rather than act on unauthenticated input.
Why the listing calls all of that a success
Section titled “Why the listing calls all of that a success”npx wawesome logs stripe-events
📜 Invocations for 'demo/stripe-events' (Showing 2 of 2 records)
INVOCATION ID | STATUS | TRIGGER | STARTED AT | DURATION
------------------------------------|-----------|---------|---------------------|----------
4e91c7a2-5b38-4d6f-9a01-7c2e8f3b6d54 | success | http | 2026-09-08 09:41:12 | 22ms
7d0b3e64-9c15-4a2b-8f37-0e5d1a9c4b28 | success | http | 2026-09-08 09:39:55 | 41ms
The rejected request is on that list as success. STATUS is not the HTTP status code: it says
whether the run finished, and a run that answers 400 finished. So npx wawesome logs stripe-events --error
will not find a forged webhook, and looking for one there is how people conclude nothing is arriving.
Follow the Function and read the Warn: lines, or open the endpoint’s delivery list in Stripe, which
does show the status code.
One command does put a failure on that column. npx wawesome invoke stripe-events fires the run
through the API instead of as a caller, and a run fired that way is success only when it answered
2xx. It sends no signature, so verification fails and the row reads error. That is a difference in
how the run was started, not in what the handler did. Logs has the full account of that
column.
Where this leaves you
Section titled “Where this leaves you”One App, two Functions, one secret that nothing reads back. To carry the endpoint further:
src/index.tsin the scaffolded directory is where an event becomes something your business does. Stripe delivers at least once and in no guaranteed order, so treatevent.idas an idempotency key.npx wawesome deployfrom that directory ships a change to this Function alone, and makes version 2 of it.- Environment variables covers rotating the secret, and what
env listshows of one. - Logs covers reading a run somebody else hit, and telling a handler’s 500 from an outage.