# Capturing form submissions into Cloudflare D1

This is the established pattern for turning a static site's contact / waitlist form into durable, queryable leads: the form POSTs to a **Pages Function**, the Function inserts a row into a **Cloudflare D1** database, and the agent reads and sweeps new rows with `wrangler d1 execute --remote`. It is live on `realagent.pages.dev` today — the worked example below is that deployment, generalized with placeholders.

Pair this with `hosting-sites.md` (how the site itself gets deployed) and `api.md` (the master token + mint-narrow discipline that authenticates every `wrangler` call).

---

## The single most common breakage — token scope

A Pages-only token **cannot touch D1**. The deploy succeeds, the form renders, and every submission silently 500s at the D1 insert. The token used for D1-backed Pages work must carry **both**:

- **Account · Cloudflare Pages · Edit**
- **Account · D1 · Edit**

Mint a narrow token that includes both permission groups (see `api.md` § Minting a narrow token) before any `wrangler d1` or D1-backed deploy command. This was observed live in the session that built `realagent.pages.dev`; it is the first thing to check when captures stop arriving.

---

## 0. Mint the Pages-+-D1 token

Every command below uses `${MINTED_PAGES_D1}` — a narrow token scoped to **both** Pages Edit and D1 Edit, minted from the master per `api.md` § Minting a narrow token. Mint it once at the start of the session and export it; it is never written to disk or echoed:

```bash
set -a; . "${SECRETS_DIR}/cloudflare.env"; set +a   # loads master + account id
MINTED_PAGES_D1=$(curl -sS -X POST \
  -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
  -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/tokens" \
  --data @- <<'JSON' | jq -r '.result.value'
{
  "name": "ephemeral-pages-d1",
  "policies": [{
    "effect": "allow",
    "resources": { "com.cloudflare.api.account.<accountId>": "*" },
    "permission_groups": [
      { "id": "<pages-edit-permission-group-id>", "name": "Pages Write" },
      { "id": "<d1-edit-permission-group-id>", "name": "D1 Write" }
    ]
  }],
  "expires_on": "<iso8601-a-few-minutes-out>"
}
JSON
)
```

Resolve the two permission-group ids once via `GET /accounts/{account_id}/tokens/permission_groups` (see `api.md`). When the work is done, `unset MINTED_PAGES_D1`.

---

## 1. Create the database

```bash
CLOUDFLARE_API_TOKEN="${MINTED_PAGES_D1}" wrangler d1 create <db-name>
# worked example: wrangler d1 create realagent-leads
```

`wrangler` prints the database `name` and `database_id`. Copy the `database_id` into `wrangler.toml` (next step). The token value is never printed — only the database identifiers.

## 2. Bind the database in `wrangler.toml`

The binding name (`DB` below) is the variable the Pages Function reads off `env`. Keep `wrangler.toml` in the site's project root — it carries no secret, only the database id.

```toml
name = "<project>"                 # worked example: realagent
pages_build_output_dir = "dist"    # or the framework's output dir

[[d1_databases]]
binding = "DB"
database_name = "<db-name>"        # realagent-leads
database_id = "<database_id>"      # from step 1
```

## 3. Create the table

Apply the schema to the remote database (`--remote` targets the live D1, not a local replica):

```bash
CLOUDFLARE_API_TOKEN="${MINTED_PAGES_D1}" wrangler d1 execute <db-name> --remote --command \
  "CREATE TABLE IF NOT EXISTS leads (
     id INTEGER PRIMARY KEY AUTOINCREMENT,
     name TEXT,
     email TEXT NOT NULL,
     message TEXT,
     created_at TEXT NOT NULL DEFAULT (datetime('now')),
     swept INTEGER NOT NULL DEFAULT 0
   );"
```

The `swept` column is the read-cursor: a row is `swept = 0` until the agent has ingested it, then flipped to `1`. This is what makes "show me new leads" a deterministic query rather than a guess.

## 4. The Pages Function — `functions/api/contact.ts`

A file at `functions/api/contact.ts` is served at `POST /api/contact` automatically (Pages Functions route by file path). It reads the `DB` binding and inserts a row. No secret lives in this file — the D1 binding is injected by the platform at runtime.

```ts
interface Env { DB: D1Database }

export const onRequestPost: PagesFunction<Env> = async ({ request, env }) => {
  const form = await request.formData();
  const email = String(form.get("email") ?? "").trim();
  if (!email) return new Response("email required", { status: 400 });

  await env.DB
    .prepare("INSERT INTO leads (name, email, message) VALUES (?, ?, ?)")
    .bind(String(form.get("name") ?? ""), email, String(form.get("message") ?? ""))
    .run();

  return new Response(null, { status: 303, headers: { Location: "/thanks" } });
};
```

The site's form posts to it:

```html
<form method="POST" action="/api/contact">
  <input name="email" type="email" required>
  <input name="name">
  <textarea name="message"></textarea>
  <button>Join the waitlist</button>
</form>
```

## 5. Deploy

Deploy via `hosting-sites.md`. The binding in `wrangler.toml` is what wires the deployed Function to D1; the deploy token must carry both Pages Edit and D1 Edit (above).

## 6. Read and sweep new submissions

Read everything not yet ingested:

```bash
CLOUDFLARE_API_TOKEN="${MINTED_PAGES_D1}" wrangler d1 execute <db-name> --remote --json --command \
  "SELECT id, name, email, message, created_at FROM leads WHERE swept = 0 ORDER BY id;"
```

After ingesting those rows (e.g. writing them into the graph), mark them swept so the next read returns only newer ones:

```bash
CLOUDFLARE_API_TOKEN="${MINTED_PAGES_D1}" wrangler d1 execute <db-name> --remote --command \
  "UPDATE leads SET swept = 1 WHERE swept = 0;"
```

---

## Outcome contract

D1 capture is done when a **test POST to the live form** appears in the unswept set:

```bash
curl -sS -X POST -d "email=test@example.com&name=verify" "https://<project>.pages.dev/api/contact" -i | head -1
# then:
CLOUDFLARE_API_TOKEN="${MINTED_PAGES_D1}" wrangler d1 execute <db-name> --remote --command \
  "SELECT email FROM leads WHERE swept = 0;"
```

The test email appearing in that `SELECT` is the proof. A 200/303 on the POST alone is not — the row in D1 is the contract. Surface the operation as verb + target ("inserting a test lead", "reading unswept rows from `realagent-leads`"); the token value never appears.
