Webhook Delivery Tutorial · Module 02 of 10

Core Webhook API

Implement the contract: let customers register webhooks and emit events. By the end, you'll have POST /webhooks, POST /events, and proper data validation.

~4–6 hrsIntermediateDatabase required
← Back to Module 02 overview
What You'll Have at the End

Definition of Done

  • POST /webhooks accepts URL, secret, event_types; stores in DB; returns webhook ID.
  • GET /webhooks/:id retrieves webhook metadata.
  • POST /events accepts event type and payload; stores immediately in DB.
  • Input validation: URL format, event type enum, max payload size.
  • Proper HTTP status codes: 201 on create, 400 on bad input, 404 on missing.
  • Database migrations committed and working.
The Steps

Build It

STEP 1

Set up database migrations

Create src/db/migrations.ts:

import { Pool } from 'pg';

export async function runMigrations(pool: Pool) {
  const client = await pool.connect();
  try {
    await client.query(`
      CREATE TABLE IF NOT EXISTS webhooks (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        url TEXT NOT NULL,
        secret TEXT NOT NULL,
        event_types TEXT[] NOT NULL,
        created_at TIMESTAMP DEFAULT NOW(),
        updated_at TIMESTAMP DEFAULT NOW()
      );

      CREATE TABLE IF NOT EXISTS events (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        type TEXT NOT NULL,
        payload JSONB NOT NULL,
        created_at TIMESTAMP DEFAULT NOW()
      );

      CREATE TABLE IF NOT EXISTS deliveries (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        webhook_id UUID NOT NULL REFERENCES webhooks(id),
        event_id UUID NOT NULL REFERENCES events(id),
        status TEXT NOT NULL DEFAULT 'pending',
        attempts INT DEFAULT 0,
        last_error TEXT,
        next_retry_at TIMESTAMP,
        created_at TIMESTAMP DEFAULT NOW(),
        updated_at TIMESTAMP DEFAULT NOW()
      );
    `);
    console.log('Migrations completed successfully');
  } finally {
    client.release();
  }
}
✓ Verify: File exists at src/db/migrations.ts.
STEP 2

Create database connection and initialize on startup

Create src/db/pool.ts:

import { Pool } from 'pg';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL || 'postgres://postgres:password@localhost:5432/webhook_service'
});

export default pool;

Update src/index.ts to run migrations:

import express from 'express';
import pool from './db/pool';
import { runMigrations } from './db/migrations';

const app = express();
app.use(express.json());

// Routes will go here

async function start() {
  try {
    await runMigrations(pool);
    const PORT = process.env.PORT || 3000;
    app.listen(PORT, () => {
      console.log(`Server running on port ${PORT}`);
    });
  } catch (err) {
    console.error('Failed to start server:', err);
    process.exit(1);
  }
}

start();
✓ Verify: npm run dev logs "Migrations completed successfully".
⚠ Gotcha: Make sure Postgres is running: docker ps should show a postgres container, or psql -U postgres should work.
STEP 3

Implement POST /webhooks

Create src/routes/webhooks.ts:

import { Router, Request, Response } from 'express';
import pool from '../db/pool';
import { v4 as uuidv4 } from 'uuid';

const router = Router();

const VALID_EVENT_TYPES = ['order.created', 'order.updated', 'user.signed_up'];

// POST /webhooks
router.post('/', async (req: Request, res: Response) => {
  const { url, secret, event_types } = req.body;

  // Validate
  if (!url || !secret) {
    return res.status(400).json({ error: 'url and secret required' });
  }
  if (!Array.isArray(event_types) || event_types.length === 0) {
    return res.status(400).json({ error: 'event_types must be non-empty array' });
  }
  if (!event_types.every(t => VALID_EVENT_TYPES.includes(t))) {
    return res.status(400).json({ error: `event_types must be one of: ${VALID_EVENT_TYPES.join(', ')}` });
  }

  try {
    const result = await pool.query(
      'INSERT INTO webhooks (url, secret, event_types) VALUES ($1, $2, $3) RETURNING *',
      [url, secret, event_types]
    );
    res.status(201).json(result.rows[0]);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Failed to create webhook' });
  }
});

// GET /webhooks/:id
router.get('/:id', async (req: Request, res: Response) => {
  try {
    const result = await pool.query(
      'SELECT * FROM webhooks WHERE id = $1',
      [req.params.id]
    );
    if (result.rows.length === 0) {
      return res.status(404).json({ error: 'Webhook not found' });
    }
    res.json(result.rows[0]);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Failed to fetch webhook' });
  }
});

export default router;

Update src/index.ts to use the router:

import webhooksRouter from './routes/webhooks';

app.use('/webhooks', webhooksRouter);
✓ Verify: Test with curl:
curl -X POST http://localhost:3000/webhooks \
  -H 'Content-Type: application/json' \
  -d '{
    "url": "https://example.com/webhook",
    "secret": "my-secret",
    "event_types": ["order.created"]
  }'
Should return 201 with the webhook ID.
STEP 4

Implement POST /events

Create src/routes/events.ts:

import { Router, Request, Response } from 'express';
import pool from '../db/pool';

const router = Router();

const VALID_EVENT_TYPES = ['order.created', 'order.updated', 'user.signed_up'];

// POST /events
router.post('/', async (req: Request, res: Response) => {
  const { type, payload } = req.body;

  // Validate
  if (!type || !VALID_EVENT_TYPES.includes(type)) {
    return res.status(400).json({ error: `type must be one of: ${VALID_EVENT_TYPES.join(', ')}` });
  }
  if (!payload || typeof payload !== 'object') {
    return res.status(400).json({ error: 'payload must be a JSON object' });
  }

  try {
    const result = await pool.query(
      'INSERT INTO events (type, payload) VALUES ($1, $2) RETURNING *',
      [type, payload]
    );
    res.status(201).json(result.rows[0]);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Failed to create event' });
  }
});

export default router;

Update src/index.ts:

import eventsRouter from './routes/events';

app.use('/events', eventsRouter);
✓ Verify: Test with curl:
curl -X POST http://localhost:3000/events \
  -H 'Content-Type: application/json' \
  -d '{
    "type": "order.created",
    "payload": {"order_id": 123, "amount": 99.99}
  }'
Should return 201 with the event ID.
STEP 5

Commit your work

git add -A
git commit -m "feat: implement core webhook and event API

- POST /webhooks: register webhook endpoints
- POST /events: emit events to the system
- Database migrations for webhooks, events, deliveries
- Input validation and proper HTTP status codes"
git push origin main
✓ Verify: git log --oneline shows your commit.
Next Steps

Ready for Module 03?

You now have the basic API contract. Next, you'll implement reliable delivery with retries and exponential backoff. Head to Module 03.