HatchedDocs
Guides

Express integration

Use @hatched/sdk-js from an Express app — handlers, raw-body webhook middleware, and error mapping.

Express is straightforward. The SDK is server-only, so everything works out of the box as long as you keep your API key in an env variable and preserve raw bodies for webhooks.

Install

pnpm add @hatched/sdk-js express

Shared client

// src/hatched.ts
import { HatchedClient } from '@hatched/sdk-js';

export const hatched = new HatchedClient({
  apiKey: process.env.HATCHED_API_KEY!,
});

Route handler example

// src/routes/events.ts
import { Router } from 'express';
import { ValidationError } from '@hatched/sdk-js';
import { hatched } from '../hatched';

export const events = Router();

events.post('/lesson-completed', async (req, res, next) => {
  try {
    const { userId, lessonId, score } = req.body;
    const effects = await hatched.events.send({
      eventId: `lesson_${lessonId}_${userId}`,
      userId,
      type: 'lesson_completed',
      properties: { lessonId, score },
    });
    res.json(effects);
  } catch (err) {
    if (err instanceof ValidationError) {
      return res.status(422).json({ error: err.details });
    }
    next(err);
  }
});

Webhook endpoint — raw body first

The default express.json() middleware parses and throws away the raw body, which breaks signature verification. Mount a raw-body parser on just the webhook path:

// src/index.ts
import express from 'express';
import { WebhooksResource } from '@hatched/sdk-js';

const app = express();

app.post(
  '/webhooks/hatched',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.header('hatched-signature') ?? '';
    const valid = WebhooksResource.verifySignature(
      req.body, // Buffer, from express.raw
      signature,
      process.env.HATCHED_WEBHOOK_SECRET!,
    );
    if (!valid) return res.status(400).send('invalid signature');

    const event = JSON.parse(req.body.toString('utf8'));
    // enqueue, etc.
    res.status(202).end();
  },
);

// JSON parser for everything else
app.use(express.json());
// ... your other routes

Centralised error mapping

import { HatchedError, RateLimitError } from '@hatched/sdk-js';

app.use((err, _req, res, _next) => {
  if (err instanceof RateLimitError) {
    res.set('Retry-After', String(err.retryAfter));
    return res.status(429).json({ error: 'rate_limited' });
  }
  if (err instanceof HatchedError) {
    return res.status(err.statusCode).json({
      error: { code: err.code, message: err.message, requestId: err.requestId },
    });
  }
  console.error(err);
  res.status(500).json({ error: 'internal_error' });
});

Graceful shutdown

If you're running long polls (operations.wait) during shutdown, pass an AbortSignal so SIGTERM can cancel them cleanly:

const controller = new AbortController();
process.on('SIGTERM', () => controller.abort());

await hatched.operations.wait(op.operationId, { signal: controller.signal });