---
title: Build a webhook receiver with a database
description: Receive a signed webhook, save each delivery once, and test the database-backed service in development before promoting it to production.
slug: /guides/build-a-webhook-receiver-with-a-database
order: 43
visibility: public
redirects_from: []
---

# Build a webhook receiver with a database

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](/cloud/getting-started/build-a-database-backed-service/), [signed webhooks](/cloud/guides/receive-a-signed-webhook/) and [development and promotion](/cloud/guides/turn-on-development-and-promote/).

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](/cloud/getting-started/build-a-database-backed-service/#before-your-ai-starts): sign in, authorize the computer and prepare an empty `app/` folder. Use npx first as [Ways to deploy](/cloud/getting-started/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](/cloud/guides/turn-on-development-and-promote/#turn-the-development-environment-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:

```sh
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:

```sql
-- 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](/cloud/getting-started/build-a-database-backed-service/#4-keep-the-schema-as-migrations)).

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

Create the module that records an event:

```js
// 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](https://www.postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT) 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](/cloud/guides/receive-a-signed-webhook/#4-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:

```js
// 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](https://docs.stripe.com/webhooks#retrieving-your-endpoints-secret) 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](/cloud/guides/receive-a-signed-webhook/#2-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:

```json
{
  "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](/cloud/guides/receive-a-signed-webhook/#5-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](/cloud/guides/run-your-application-on-your-machine/) 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](/cloud/guides/receive-a-signed-webhook/#7-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](/cloud/getting-started/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](/cloud/guides/turn-on-development-and-promote/#what-a-promote-does-to-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](/cloud/guides/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](/cloud/reference/refusals/) for the other causes and remedies.

## Related

- [Build a database-backed service](/cloud/getting-started/build-a-database-backed-service/) explains the template, pool, migrations and health route.
- [Receive a signed webhook](/cloud/guides/receive-a-signed-webhook/) supplies the signature check and local sender.
- [Test in development, then promote](/cloud/guides/turn-on-development-and-promote/) explains release mechanics.
- [Applications and environments](/cloud/concepts/applications-and-environments/) explains which records local and hosted copies share.
