Build a webhook receiver with a database #

View this page as Markdown

Prompt:

Stripe should notify my backend about payments and keep the events in a database. Test it before it goes live.

Also works: "Keep signed payment notifications and check the receiver in development first."

Build one service that checks a webhook's signature, saves an accepted event once, and passes development checks before production. This walkthrough joins the database service, signed webhooks and development and promotion.

The example uses Stripe's signing scheme. A sender needs no Turn Zero credential. The receiver checks the signature itself; it exposes no route for visitors to list saved events.

What your AI tool does #

  • Reads the deploy, store-key, develop-then-promote and release skills with read_context.
  • Asks which plan to use and obtains each signing secret through a private file, never through chat.
  • Asks before promoting unless the owner already requested it, and makes production write checks only where the owner judges them safe.

What you need #

  • A sender that signs webhook deliveries, and access to configure its endpoints and obtain their signing secrets.
  • Node.js 24 or later and npm for the local build and tests.
  • A Turn Zero Cloud account on any plan; every plan supports development.

Before your AI starts #

These steps are for your AI tool.

Follow the database walkthrough's prerequisites: sign in, authorize the computer and prepare an empty app/ folder. Use npx first as Ways to deploy directs. On Windows, use npx.cmd in place of npx.

Keep secret files outside app/. The deploy includes the application's files, so a private signing-secret file must never enter that folder or a committed file.

Steps #

1. Create the application and turn development on #

Create the application on the plan the owner chose. Before any deploy, call create_environment with its identifier and environment: "development". Otherwise the first deploy reaches production.

Keep the application's label and the returned development hostname. The webhook addresses are https://<label>-dev.ai.host/webhooks/stripe and https://<label>.ai.host/webhooks/stripe. Turn development on explains the environments and what that call creates.

2. Prepare the service and its first migration #

From the project root, scaffold the database service:

npx --no-update-notifier -y turnzero@latest scaffold --template database-service --application <application id> --resource deliveries --singular delivery --fields "sender:text,event_id:text,event_type:text"

Before the service first starts, replace its first migration with:

-- migrations/0001_deliveries.sql
CREATE TABLE deliveries (
  id bigserial PRIMARY KEY,
  sender text NOT NULL,
  event_id text NOT NULL,
  event_type text NOT NULL,
  payload jsonb NOT NULL,
  UNIQUE (sender, event_id)
);

Keep the constraint in that first migration. Once a start has applied a migration, change the schema through a new migration instead of editing the old file (Keep the schema as migrations).

3. Save verified deliveries and replace the public CRUD routes #

Create the module that records an event:

// src/deliveries.mjs
export async function saveDelivery(pool, event) {
  const { rows } = await pool.query(
    `INSERT INTO deliveries (sender, event_id, event_type, payload)
     VALUES ($1, $2, $3, $4::jsonb)
     ON CONFLICT (sender, event_id) DO NOTHING
     RETURNING event_id`,
    ['stripe', event.id, event.type, JSON.stringify(event)],
  );
  return rows.length === 1;
}

The PostgreSQL conflict clause leaves an already saved event unchanged. This prevents duplicate storage, not duplicate execution of arbitrary business actions you later add.

In src/db.mjs, import saveDelivery from ./deliveries.mjs. Replace the generated create and list methods with recordDelivery(event) { return saveDelivery(pool, event); } inside createDataLayer's returned object. Keep openLivePool, migrate and reachable unchanged.

Save the signature sample from Check the signature in the route as src/webhook-signature.mjs. Keep its crypto import and exported signatureIsValid; omit the old logging-only handler. Then add:

// src/webhook.mjs
import { signatureIsValid } from './webhook-signature.mjs';

export async function handleStripeWebhook(request, response, db) {
  try {
    const chunks = [];
    for await (const chunk of request) chunks.push(chunk);
    const raw = Buffer.concat(chunks).toString('utf8');
    if (!signatureIsValid(raw, request.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET)) {
      console.log('webhook refused: no valid signature');
      if (!response.destroyed) response.writeHead(400).end();
      return;
    }
    const event = JSON.parse(raw);
    if (typeof event?.id !== 'string' || !event.id || typeof event.type !== 'string' || !event.type) {
      if (!response.destroyed) response.writeHead(400).end();
      return;
    }
    await db.recordDelivery(event);
    console.log('webhook', event.type, event.id);
    if (!response.destroyed) response.writeHead(200).end();
  } catch (error) {
    if (!(error instanceof SyntaxError)) console.error('webhook failed');
    if (!response.destroyed) response.writeHead(error instanceof SyntaxError ? 400 : 500).end();
  }
}

In src/server.mjs, import the handler from ./webhook.mjs. Replace the generated create and list routes with if (request.method === 'POST' && pathname === '/webhooks/stripe') return await handleStripeWebhook(request, response, db);. Keep /health, the abort-signal handling and the final 404.

The handler verifies the unchanged body before parsing or saving it. It answers 200 only after the database write, including an already recorded duplicate. A write failure answers 500 so the sender can retry. Log the event's type and id, never its secret or full payload.

4. Configure the sender and store each secret #

Give the owner both webhook addresses to register in the sender's dashboard. Ask for each endpoint's signing secret as a private file outside app/. Stripe's endpoint setup explains endpoint-specific test and live secrets. Ask the owner to keep live deliveries off until production is ready.

Store the test secret for development and the live secret for production. Call store_secret with the application, name: "stripe-webhook-secret", the explicit environment, and the private value_file path. Run its returned command once, as given, for each environment.

Without environment, that action stores in production. Never use generate for a sender's signing secret: the sender must hold the matching value. Store the shared secret covers provider-issued and self-chosen values.

5. Bind the secret and submit the whole manifest #

Keep the scaffold's database service and library package rows. Merge this setting into its manifest:

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

Submit the whole manifest, not this fragment. Check that each environment's binding reads stored: true. A missing bound secret refuses that environment's deploy or promotion.

For an invited or workforce audience, list /webhooks/stripe in session_free_paths, as Open the path under a private audience explains. The signature then protects that route.

Leave the webhook path out of smoke_paths: smoke checks use GET, while this route accepts POST. Keep /health as the health path.

6. Check the receiver locally #

Replace the generated CRUD tests with receiver tests over the Database package's double. Test a valid delivery, its duplicate, changed body, old timestamp, missing signature and failed write. Check stored rows as well as HTTP status.

Run npm test in app/. Then follow Run your app on your computer to provision the settings and start it. Add your own local test value as STRIPE_WEBHOOK_SECRET in the ignored .env; provision writes no bound-secret value and preserves that line.

Local runs share development's database. The run command gives .env precedence over the shell. Keep the process running while you test.

Save scripts/verify-webhook.mjs from Confirm a delivery. From app/, run node --env-file=.env scripts/verify-webhook.mjs <local webhook URL> twice. Replace <local webhook URL> with the running receiver's actual /webhooks/stripe address.

Unset a conflicting STRIPE_WEBHOOK_SECRET in that shell first: for this Node command, the shell value wins over .env. Both processes must use the same local test secret. The signed event, evt_local_check, should still occupy one row; changed and unsigned bodies should add none.

Read that row with query_database, explicitly naming environment: "development". Do not read production by leaving out the environment. Stop the process you started when finished.

7. Deploy and check development #

Check and deploy through Ways to deploy, then read the outcome to its end. Development now receives deploys; a deploy cannot select production on a two-environment application.

Ask the owner to send a test event from the sender's test endpoint. Read read_logs with environment: "development" and source: "container"; look for its webhook line. Read again about a minute later if needed, since log delivery can lag.

Use query_database with environment: "development" to confirm the event was saved once. An unsigned POST through request_route, also naming development, should answer 400 without adding a row. Passing the deploy health check alone does not prove these routes work.

8. Promote the checked version #

Read list_versions and its harness_current before promoting. A promote reuses development's image; it does not rebuild it.

Only after the owner's agreement or prior request to promote, call promote with the application and wait_seconds. Confirm the selected version. If settled is false, make the returned next call and keep reading to completion.

Production uses its own stored signing secret. Its first promotion creates its database, and the new process applies the migrations there. It copies no development records. Production's database explains this separation.

9. Check production and report what was verified #

Request the production health path. After a permitted live delivery, read container logs and query the saved event, explicitly naming environment: "production". Read logs again about a minute later when needed.

Send no production webhook test unless the owner judges that write safe. The platform signs no test delivery with a stored secret. If no signed production delivery was checked, report that limit instead of claiming the receiver was verified there.

On a failure, report whether a migration ran. Roll back on the owner's word; rollback restores code, not the migration's data (Manage versions and environments).

Expected result #

The checked version serves in both environments. Valid signed deliveries return 200 and are saved once; a changed body, old timestamp or missing signature returns 400 and saves nothing. A failed database write returns 500.

Each database records its applied migration in schema_migrations. Report the version, the environments actually checked, and any production write check left unrun.

Refusals #

Refusal Status Cause Remedy
setting_secret_missing 409 An environment lacks a bound secret. Store that environment's value, then repeat the refused operation.
authentication_required 401 A private audience blocked the sender before the route. Configure the webhook's session_free_paths entry and keep signature verification in the handler.
environment_unavailable 409 A deploy selected production while development exists. Deploy to development and promote the checked version.
nothing_to_promote 409 Development has no version to promote. Deploy and check development first.
query_not_read_only 400 query_database was given a statement that writes. Use a read-only query to inspect the saved event.

See Refusals for the other causes and remedies.