# Express webhook receiver

Source: https://stormedo.com/examples/express-webhook-receiver/

Run an Express endpoint that verifies Stormedo signatures, logs the received JSON event, and acknowledges delivery.

Receive a JSON event, verify its Stormedo signature, and print it in your terminal. The receiver returns `204` to acknowledge the delivery.

## Install and run

Use Node.js 22 or newer. This example was exercised with Node 22.20.0, Express 5.2.1, and the Stormedo SDK 0.1.0-alpha.0 release candidate.

```bash
mkdir stormedo-express-receiver
cd stormedo-express-receiver
npm init -y
npm install express@5.2.1 @stormedo/sdk
curl --fail --output receiver.mjs \
  https://stormedo.com/examples/express-webhook-receiver/receiver.mjs
```

Set the receiver configuration, then start it:

```bash
export STORMEDO_PROJECT_ID='prj_...'
export PUBLIC_WEBHOOK_URL='https://your-app.example/events'
node receiver.mjs
```

It listens on `127.0.0.1:3000`. Configure your HTTPS reverse proxy or tunnel to forward to that address. Register this raw-body route before any application-wide `express.json()` middleware.

Obtain the actual project ID from `data.project_id` in a successful [quickstart send](https://stormedo.com/docs/get-started/first-request/). Configure the complete public URL in both the receiver and publisher, including any query string. Receiving needs the project ID, not the publisher API key.

## Complete receiver

[Download receiver.mjs](https://stormedo.com/examples/express-webhook-receiver/receiver.mjs).

```js
import express from 'express';
import { pathToFileURL } from 'node:url';
import { DeliveryVerifier, DeliveryVerificationError } from '@stormedo/sdk';

export function createReceiver({ projectId, publicUrl, verifier }) {
  if (!projectId) throw new Error('Set STORMEDO_PROJECT_ID.');
  const destination = new URL(publicUrl);
  if (destination.protocol !== 'https:' || destination.username || destination.password || destination.hash) {
    throw new Error('PUBLIC_WEBHOOK_URL must be the public HTTPS destination URL.');
  }
  verifier ??= new DeliveryVerifier(projectId);
  const app = express();
  app.disable('x-powered-by');

  app.post(destination.pathname, express.raw({ type: () => true, limit: '256kb', inflate: false }), async (req, res, next) => {
    try {
      const singleHeader = (name) => {
        const values = [];
        for (let i = 0; i < req.rawHeaders.length; i += 2) {
          if (req.rawHeaders[i].toLowerCase() === name) values.push(req.rawHeaders[i + 1]);
        }
        return values.length === 1 && values[0] ? values[0] : null;
      };
      const signature = singleHeader('stormedo-signature');
      const requestId = singleHeader('stormedo-request-id');
      if (!signature || !requestId) return res.status(400).end();
      const rawBody = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
      const delivery = await verifier.verifyDelivery({
        signature, requestId, expectedUrl: destination.href, method: req.method, rawBody,
      });
      let event;
      try { event = JSON.parse(rawBody.toString('utf8')); }
      catch { return res.status(400).end(); }
      console.log('Received event:', delivery.requestId, event);
      return res.status(204).end();
    } catch (error) {
      if (error instanceof DeliveryVerificationError) {
        console.error('Delivery verification failed:', error.code);
        return res.status(error.code === 'jwks_unavailable' ? 503 : 401).end();
      }
      next(error);
    }
  });
  app.use((error, _req, res, _next) => {
    const status = error.type === 'entity.too.large' ? 413 : error.type === 'encoding.unsupported' ? 415 : 500;
    if (status === 500) console.error('Receiver could not process the delivery.');
    res.status(status).end();
  });
  return app;
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  const app = createReceiver({
    projectId: process.env.STORMEDO_PROJECT_ID,
    publicUrl: process.env.PUBLIC_WEBHOOK_URL,
  });
  app.listen(3000, '127.0.0.1', () => console.log('Receiver listening on http://127.0.0.1:3000'));
}
```

## Send a signed event

From another terminal, set `STORMEDO_TOKEN` to the project API key and `PUBLIC_WEBHOOK_URL` to the receiver’s public HTTPS URL. This command uses curl 7.87 or newer for `--url-query`:

```bash
curl --request POST 'https://api.stormedo.com/send' \
  --url-query "url=$PUBLIC_WEBHOOK_URL" \
  --url-query 'retry=3' \
  --header "Authorization: Bearer $STORMEDO_TOKEN" \
  --header 'Idempotency-Key: order-123-created' \
  --header 'Content-Type: application/json' \
  --data '{"type":"order.created","order_id":"ord_123"}'
```

Stormedo signs the outbound attempt automatically. Expect API `202`, a `Received event:` line in the receiver’s terminal, and destination `204` in [request history](https://stormedo.com/docs/api-reference/request-history/).

## Before adding business logic

This example logs each verified event. Stormedo provides at-least-once delivery, so retries can print the same event again. Signature verification does not prevent duplicates.

Before replacing the log with an action such as updating an order or sending an email, validate the event fields and add [receiver idempotency](https://stormedo.com/docs/concepts/idempotency/#receiver-idempotency). Return `2xx` only after your work or a durable queue handoff has committed.

The receiver rejects missing or duplicate signing headers and bodies over 256 KiB. Invalid signatures return `401`; a temporary public-key fetch failure returns `503` so Stormedo can retry if attempts remain. Keep the raw body intact until verification finishes.

See [Receive deliveries](https://stormedo.com/docs/how-to/receive-deliveries/) for configuration and response handling, or [Troubleshoot delivery](https://stormedo.com/docs/how-to/troubleshoot-delivery/) for failed attempts.
