Skip to content

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 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

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.

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.

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.completed
  • payment_intent.succeeded
  • payment_intent.payment_failed
  • customer.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.

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.

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.

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.

One App, two Functions, one secret that nothing reads back. To carry the endpoint further:

  • src/index.ts in the scaffolded directory is where an event becomes something your business does. Stripe delivers at least once and in no guaranteed order, so treat event.id as an idempotency key.
  • npx wawesome deploy from that directory ships a change to this Function alone, and makes version 2 of it.
  • Environment variables covers rotating the secret, and what env list shows of one.
  • Logs covers reading a run somebody else hit, and telling a handler’s 500 from an outage.