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:
datetime('now')- SQLite function for UTC timestamps- Indexes on
sourceandreceived_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 onsourcescans 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.