D1 CRUD

Create a D1 database, define a webhooks schema, run a migration, and build CRUD routes. This quickstart adds persistent storage to the Webhook Hub.

Prerequisites: Storage Landscape, First Worker

Create the Database

npx wrangler d1 create webhook-hub-db

This outputs the database ID. Add it to wrangler.jsonc:

{
  "name": "webhook-hub",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "compatibility_flags": ["nodejs_compat"],

  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "webhook-hub-db",
      "database_id": "<paste-your-database-id>"
    }
  ]
}

Re-generate types so Env includes the D1 binding:

npx wrangler types

Now Env contains DB: D1Database, and c.env.DB is fully typed in Hono.

Write the Schema

Create schema.sql in your project root:

CREATE TABLE IF NOT EXISTS webhooks (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  source TEXT NOT NULL,
  event_type TEXT NOT NULL DEFAULT 'unknown',
  payload TEXT NOT NULL,
  received_at TEXT NOT NULL DEFAULT (datetime('now'))
);

CREATE INDEX IF NOT EXISTS idx_webhooks_source ON webhooks(source);
CREATE INDEX IF NOT EXISTS idx_webhooks_received_at ON webhooks(received_at);

Two things to note:

  1. datetime('now') - SQLite function for UTC timestamps
  2. Indexes on source and received_at - these are the columns you’ll filter and sort by. Without indexes, every query scans the full table.

Gotcha: D1 pricing is based on “rows read,” which means rows scanned, not rows returned. A SELECT * FROM webhooks WHERE source = 'github' without an index on source scans every row in the table. That costs real money at scale. Always index your filter columns.

Run the Migration

# Apply locally (for development)
npx wrangler d1 execute webhook-hub-db --local --file=schema.sql

# Apply to production
npx wrangler d1 execute webhook-hub-db --remote --file=schema.sql

You can also run ad-hoc SQL:

npx wrangler d1 execute webhook-hub-db --local --command="SELECT count(*) FROM webhooks"

CRUD Routes

Update src/index.ts to add database operations:

import { Hono } from "hono";

const app = new Hono<{ Bindings: Env }>();

// CREATE - receive a webhook
app.post("/webhook/:source", async (c) => {
  const source = c.req.param("source");
  const payload = await c.req.text();
  const eventType = c.req.header("X-Event-Type") ?? "unknown";

  const result = await c.env.DB.prepare(
    "INSERT INTO webhooks (source, event_type, payload) VALUES (?, ?, ?)"
  )
    .bind(source, eventType, payload)
    .run();

  return c.json(
    { id: result.meta.last_row_id, source, event_type: eventType },
    201
  );
});

// READ - list webhooks with optional source filter
app.get("/webhooks", async (c) => {
  const source = c.req.query("source");
  const limit = parseInt(c.req.query("limit") ?? "50");

  let stmt;
  if (source) {
    stmt = c.env.DB.prepare(
      "SELECT * FROM webhooks WHERE source = ? ORDER BY received_at DESC LIMIT ?"
    ).bind(source, limit);
  } else {
    stmt = c.env.DB.prepare(
      "SELECT * FROM webhooks ORDER BY received_at DESC LIMIT ?"
    ).bind(limit);
  }

  const { results } = await stmt.all();
  return c.json({ webhooks: results, count: results.length });
});

// READ - single webhook by ID
app.get("/webhooks/:id", async (c) => {
  const id = parseInt(c.req.param("id"));
  const webhook = await c.env.DB.prepare(
    "SELECT * FROM webhooks WHERE id = ?"
  )
    .bind(id)
    .first();

  if (!webhook) {
    return c.json({ error: "not found" }, 404);
  }
  return c.json(webhook);
});

// DELETE - remove a webhook by ID
app.delete("/webhooks/:id", async (c) => {
  const id = parseInt(c.req.param("id"));
  const result = await c.env.DB.prepare("DELETE FROM webhooks WHERE id = ?")
    .bind(id)
    .run();

  if (result.meta.changes === 0) {
    return c.json({ error: "not found" }, 404);
  }
  return c.json({ deleted: true });
});

export default app;

Prepared Statements

Always use prepared statements with .bind(). Never interpolate values into SQL strings:

// GOOD - parameterized query, safe from SQL injection
const result = await c.env.DB.prepare(
  "SELECT * FROM webhooks WHERE source = ? AND event_type = ?"
)
  .bind(source, eventType)
  .all();

// BAD - string interpolation, vulnerable to SQL injection
const result = await c.env.DB.prepare(
  `SELECT * FROM webhooks WHERE source = '${source}'`
).all();

Batch Operations

D1 supports batching multiple statements in a single round-trip. Use this for transactions or bulk operations:

// Insert multiple webhooks in one batch
const webhooks = [
  { source: "github", type: "push", payload: "{}" },
  { source: "stripe", type: "payment", payload: "{}" },
  { source: "github", type: "pr", payload: "{}" },
];

const stmts = webhooks.map((w) =>
  c.env.DB.prepare(
    "INSERT INTO webhooks (source, event_type, payload) VALUES (?, ?, ?)"
  ).bind(w.source, w.type, w.payload)
);

// All statements execute in a single transaction
const results = await c.env.DB.batch(stmts);

batch() executes all statements atomically. If any statement fails, the entire batch rolls back.

Useful Queries

// Count webhooks by source
const stats = await c.env.DB.prepare(
  "SELECT source, count(*) as count FROM webhooks GROUP BY source"
).all();

// Delete webhooks older than 30 days
const cleanup = await c.env.DB.prepare(
  "DELETE FROM webhooks WHERE received_at < datetime('now', '-30 days')"
).run();
console.log(`Cleaned up ${cleanup.meta.changes} old webhooks`);

// Check if a webhook exists (use first() for single row)
const exists = await c.env.DB.prepare(
  "SELECT 1 FROM webhooks WHERE id = ?"
)
  .bind(id)
  .first();

Testing Locally

npx wrangler dev

# Create a webhook
curl -X POST http://localhost:8787/webhook/github \
  -H "Content-Type: application/json" \
  -H "X-Event-Type: push" \
  -d '{"ref": "refs/heads/main", "commits": []}'

# List all webhooks
curl http://localhost:8787/webhooks

# Filter by source
curl http://localhost:8787/webhooks?source=github

# Get by ID
curl http://localhost:8787/webhooks/1

# Delete
curl -X DELETE http://localhost:8787/webhooks/1

wrangler dev creates a local D1 database automatically. Your local data is stored in .wrangler/state/ and persists across dev sessions.

Webhook Hub Progress

After this quickstart, the Webhook Hub can:

  • Receive webhooks from any source via POST /webhook/:source
  • Store them in D1 with source, event type, and timestamp
  • Query, filter, and delete stored webhooks

Next: R2 Files adds object storage for large payloads, and KV Caching adds per-source rate limiting.

Forward reference: Durable Objects covers D1 inside Durable Objects for strongly consistent per-actor state.