> ## Documentation Index
> Fetch the complete documentation index at: https://docs.pre.dev/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> Agents: start with https://docs.pre.dev/agents.md, which has complete recipes, plan access, polling rules, errors and limits.
> Authenticate with the workspace API key (pdk_…) from Integrations → Built-in, sent as Authorization: Bearer <key>.
> REST API: https://api.pre.dev (OpenAPI: https://docs.pre.dev/api-reference/openapi.json). AI Gateway: https://api.pre.dev/v1, OpenAI-compatible (OpenAPI: https://docs.pre.dev/api-reference/ai-gateway.openapi.json).
> pre.dev MCP server: https://api.pre.dev/mcp. Search these docs over MCP at https://docs.pre.dev/mcp.

# Webhooks

> Stripe events are delivered to your app's server in the project's sandbox, signed with your project's own secret.

Stripe cannot reach a sandbox, so pre.dev receives the Stripe events for your workspace and relays them to your app. There is no endpoint to register and no signing secret to copy.

## Where events arrive

pre.dev posts each event to **`POST /api/stripe/webhook`** on your app's server, which runs in the project's sandbox. That is true after you publish too: a [published](/coding-agent/building/publish) app's address serves only its built front end, so events go to the sandbox. Sessions with their own sandbox receive the events as well. Mount your handler on exactly that path.

The request looks exactly like one from Stripe: a JSON event body and a `Stripe-Signature` header. Verify it with the Stripe SDK and `STRIPE_WEBHOOK_SECRET`, which is this project's own secret.

<CodeGroup>
  ```typescript Express theme={null}
  import express from 'express';
  import { stripe } from './stripe';

  app.post('/api/stripe/webhook', express.raw({ type: 'application/json' }), (req, res) => {
    let event;
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.headers['stripe-signature'] as string,
        process.env.STRIPE_WEBHOOK_SECRET!,
      );
    } catch (err) {
      return res.status(400).send('invalid signature');
    }

    switch (event.type) {
      case 'checkout.session.completed':
        // mark the order paid, grant access
        break;
      case 'customer.subscription.deleted':
        // revoke access
        break;
      default:
        // other events are normal; ignore what you do not handle
    }
    res.json({ received: true });
  });
  ```

  ```python Flask theme={null}
  import os
  import stripe
  from flask import request, abort

  @app.post("/api/stripe/webhook")
  def stripe_webhook():
      try:
          event = stripe.Webhook.construct_event(
              request.get_data(),
              request.headers.get("Stripe-Signature", ""),
              os.environ["STRIPE_WEBHOOK_SECRET"],
          )
      except Exception:
          abort(400)

      if event["type"] == "checkout.session.completed":
          pass  # mark the order paid, grant access
      return {"received": True}
  ```
</CodeGroup>

The handler must read the **raw** request body. A JSON body parser that runs first changes the bytes and the signature check fails.

## Which projects receive events

Each Stripe event for your workspace's account goes to every project in the workspace that called Stripe through pre.dev in the last 30 days, up to 25 projects, most recent first. A project whose deliveries are paused does not receive events.

Projects in a workspace share its Stripe account, so a project also receives events caused by the workspace's other projects, whether it is in test or live mode. Match each event to your own records, for example by customer or metadata, and return `2xx` for events you do not handle. Typical events for subscriptions are `checkout.session.completed`, `invoice.paid`, `invoice.payment_failed`, and `customer.subscription.updated` or `deleted`.

## Retries and pausing

A delivery that fails with a timeout or a `5xx` is retried with backoff for about 45 minutes. A `4xx` other than `408` or `429` is not retried.

If your app answers `404` at the webhook path three times in a row, deliveries to that project pause. They resume after the app next calls Stripe through pre.dev, which can take up to 10 minutes to register, so mount the route, reload, and make a Stripe call.

## Do not register an endpoint

Calling `stripe.webhookEndpoints.create` through the built-in key returns a `400` that explains delivery is built in. Your own Stripe dashboard is not involved while in test mode.

## Testing a flow end to end

1. Start a Checkout session in your app and pay with `4242 4242 4242 4242`.
2. Watch the server log for `checkout.session.completed` arriving at `/api/stripe/webhook`.
3. Confirm your handler did its work, for example a row marked paid.

The build agent does exactly this before it reports a payments feature done.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.