Receive a signed webhook

Prompt:

Stripe should tell the backend when a payment goes through. Set up the webhook.

Also works:

  • "Accept the payment provider's callbacks."
  • "Receive GitHub's push events on the backend."

What your tool does

  • Reads the platform's store-key skill and the manifest's settings rules.
  • Asks you for the signing secret the sender issued for each environment, saved in a file outside the application's folder. It never asks for the value itself.
  • Calls store_secret once for each environment, naming the file as value_file, and runs the line each call returns. It never names generate for this secret.
  • Binds the stored name to a setting in the manifest's settings, and submits the manifest again with submit_manifest.
  • Writes the webhook's route: it reads the raw body, checks the signature against the setting, and refuses a delivery without a valid one before acting on it.
  • Under an invited or workforce audience, lists the route's path in the audience's session_free_paths.
  • Deploys, and promotes where the application has a development environment.
  • Gives you the route's address to register with the sender.
  • Once the sender sends a test event, calls read_logs to confirm the delivery reached the route.

What you need

  • The signing secret the sender issued for each environment, from the sender's dashboard. Stripe issues one for test mode and another for live mode.
  • An application that deploys (Deploy an application).

Before your AI starts

This section is for your AI tool: what it checks and gathers before it begins. You don't need to do these steps yourself.

  • Your tool connected and signed in (Connect your tool).
  • list_applications returns the application's identifier, and read_status names its environments.
  • Each secret's file sits outside the application's folder, because a deploy's zip includes every file of that folder.

Steps

The steps follow the sender's deliveries from the secret to the logs. The example is a Stripe webhook at the path /webhooks/stripe, the same secret Store a secret works through.

1. Choose the environments

Each environment receives its own deliveries and checks them with its own secret. Production takes the sender's live secret. A development environment, once turned on, takes the test one (Turn on a development environment and promote).

2. Store the shared secret

The sender signs each delivery with the secret, and your route checks the signature with the same secret. So the stored value is the one the sender issued.

Your tool calls store_secret with the name stripe-webhook-secret, the application, the environment, and the secret's file as value_file. It runs the line the call returns, once for each environment. Supply the value without exposing it says who runs the line.

Never store this secret with generate. A created value is never returned, so no sender can hold it, and no delivery could carry a valid signature.

3. Bind it to a setting

Your tool adds the binding to the manifest's settings and submits the whole manifest again with submit_manifest. No test checks this sample.

"settings": {
  "STRIPE_WEBHOOK_SECRET": { "secret": "stripe-webhook-secret" }
}

The response's settings rows say whether the name is stored at each environment's scope. Bind a stored value to a setting gives the rules for the member.

4. Check the signature in the route

The route reads the body as raw text, checks the Stripe-Signature header against the setting, and only then parses the JSON. Another sender names its own header and signing scheme in its documentation, and the check keeps the same shape. A delivery without a valid signature is refused with 400 and changes nothing. A test runs this sample against a signed delivery, a changed body, an old timestamp, and a missing header.

import { createHmac, timingSafeEqual } from 'node:crypto';

// True where the header signs this raw body with the secret, within five minutes.
export function signatureIsValid(rawBody, header, secret, nowSeconds = Math.floor(Date.now() / 1000)) {
  if (typeof header !== 'string' || !secret) return false;
  const fields = header.split(',').map((field) => field.split('='));
  const timestamp = Number(fields.find(([key]) => key === 't')?.[1]);
  if (!Number.isInteger(timestamp) || Math.abs(nowSeconds - timestamp) > 300) return false;
  const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest();
  return fields.some(([key, value]) => key === 'v1' && /^[0-9a-f]{64}$/.test(value ?? '') && timingSafeEqual(Buffer.from(value, 'hex'), expected));
}

// The route at POST /webhooks/stripe.
export async function handleStripeWebhook(req, res) {
  const chunks = [];
  for await (const chunk of req) chunks.push(chunk);
  const rawBody = Buffer.concat(chunks).toString('utf8');
  if (!signatureIsValid(rawBody, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET)) {
    console.log('webhook refused: no valid signature');
    res.writeHead(400).end();
    return;
  }
  const event = JSON.parse(rawBody);
  console.log(`webhook ${event.type} ${event.id}`);
  res.writeHead(200).end();
}

A sender may deliver the same event more than once, so the route acts on each event's id once. Each delivery writes one console line, which step 7 reads.

5. Open the path under a private audience

Under a public audience, every request reaches the route already. Under invited or workforce, a request without an end-user session is refused, and a sender has none. So your tool lists the route's path in the audience's session_free_paths, for example {"kind": "invited", "session_free_paths": ["/webhooks/stripe"]}.

The platform lets every request to a listed path through without checking who sent it. That is why step 4's check is the route's own. Choose who may sign up gives the list's rules.

6. Deploy, promote, and register the address

Your tool deploys as Deploy an application describes. A deploy reads the value stored for its environment and injects it as STRIPE_WEBHOOK_SECRET. A promote reads the production value the same way.

Your tool then gives you the route's address on each environment's hostname, such as https://<hostname>/webhooks/stripe. You register it in the sender's dashboard, the live address with the live secret and the test address with the test one.

7. Confirm a delivery

You send a test event from the sender's dashboard. Your tool then calls read_logs for that environment with the source container, which returns the route's console lines. A line that opens webhook and names the event's type means the delivery passed the check.

A line that reads webhook refused means the signature did not match: the stored secret is not the one the sender used for that address. Read logs and counters describes the sources.

Expected result

A delivery the sender signed reaches the route, passes the check, and answers 200, and its console line names the event. A delivery with a missing or wrong signature answers 400 and changes nothing. Each environment checks its own deliveries with its own secret.

Refusals

Refusal Status Cause Remedy
setting_secret_missing 409 A deploy or promote found a bound name not stored at its environment's scope; detail names the setting and the name. Store the value there with store_secret, as Store a secret describes, naming the application and the environment, then deploy or promote again.
manifest_invalid 400 The manifest does not match the schema; violations lists the failing paths. A session_free_paths entry that breaks a rule is refused at its own place in the list. Correct each path and resubmit the whole manifest.
authentication_required 401 Under an invited or workforce audience, a delivery reached a path the audience does not list as session-free. List the route's path in the audience's session_free_paths, submit the manifest again, and deploy.