Post

How to e2e test webhooks πŸ“¬

How to e2e test webhooks πŸ“¬

Let’s paint the picture 🎨.

Webhooks are a strong mechanism and are used by many to send real-time updates about any kind of event.

When something happens on service X, it sends an HTTP-request to service Y’s endpoint, notifying it of a change.

Examples:

  • πŸ› οΈ GitHub / GitLab β†’ sends a webhook to your CI pipeline whenever you push code
  • 🚚 Amazon / Uber β†’ sends a webhook to update delivery or ride status in real time
  • πŸ”— Moralis / CryptoAPIs β†’ sends a webhook for onchain transactions involving your wallet
  • etc 🌍

Our playground case scenario πŸ“œ.

System overview πŸ‘€

Let’s say we have a delivery service:

  • 🚚 We deliver orders to addresses β€” the final destination of each package.
  • πŸ—‚οΈ Every delivery has its own metadata β€” details that can change while the order is in progress.
  • πŸ”„ If anything changes about an ongoing delivery, we need to notify everyone involved: client, delivery man, integrated services, apps, and more.
  • πŸ“‘ We handle all these updates via webhooks.

Here’s an example metadata for our delivery service:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
  "orderId": "4b2192f5-6cd4-4a3d-9b72-1674eb98bca4",
  "status": "delivering",
  "paid": true,
  "leaveAtTheDoor": true,
  "deliveryAddress": {
    "city": "Sydney",
    "street": "Wallaby Way",
    "house": "42",
    "flat": "3",
    "entrance": "2",
    "intercom": "2",
    "comment": "Please ring and leave at the door if no answer"
  },
  "clientInfo": {
    "name": "Peter Gaevoy",
    "phone": "+61 999 322 633"
  }
}

Webhook delivery client setup 🚚

Now we create simple client that will send webhooks for our delivery.

🧾 Declare webhook data types

β†’ Define the structure of a webhook payload to keep things type-safe and tidy.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 export interface DeliveryAddress {
    city: string;
    street: string;
    house: string;
    flat: string;
    entrance: string;
    intercom: string;
    comment: string;
}

export interface ClientInfo {
    name: string;
    phone: string;
}

export interface DeliveryWebhook {
  orderId: string;
  status: string;
  paid: boolean;
  leaveAtTheDoor: boolean;
  deliveryAddress: DeliveryAddress;
  clientInfo: ClientInfo;
}

πŸ—οΈ Create a builder to generate and modify delivery data

β†’ This makes it easy to construct valid payloads dynamically.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { DeliveryWebhook } from "../../types/delivery/webhook-types";
import { v4 as uuidv4 } from 'uuid';

export const buildDeliveryWebhook = (overrides: Partial<DeliveryWebhook> = {}): DeliveryWebhook => {
  return {
  orderId: uuidv4(),
  status: "delivering",
  paid: true,
  leaveAtTheDoor: true,
  deliveryAddress: 
    {
      city: "Sydney",
      street: "Wallaby Way",
      house: "42",
      flat: "3",
      entrance: "2",
      intercom: "2",
      comment: "Please ring and leave at the door if no answer"
    }
  ,
  clientInfo: {
    name: "Peter Gaevoy",
    phone: "+61 999 322 633"
  },
  ...overrides,
}
};

πŸ”„ It returns a complete DeliveryWebhook object with default values filled in.
If you want to change any of the fields, simply pass an object with your custom values using the overrides parameter β€” those values will replace the defaults.

πŸ“€ Write a function that sends a webhook

β†’ A simple function to POST crafted payloads.

1
2
3
4
5
6
7
8
9
10
11
12
 async function sendWebhookWithCustomStatus(webhookUrl: string, statuses: string[]) {
  for (const status of statuses) {
    const requestBody = buildDeliveryWebhook({ status });
    const response = await fetch(webhookUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(requestBody),
    });

    expect(response.status).toBe(200);
  }
}

πŸš€ Sends webhook requests sequentially with specified statuses to the given webhook URL.
βœ… Asserts that each request returns HTTP 200 status.

DONE
Now we can simulate webhooks being sent by our delivery service.

Webhook receiver and validator 😎

πŸŽ‰ Now begins the FUN part! 🀩
To validate that our system actually sends webhooks, we need to create an endpoint to receive and verify them.

πŸ› οΈ Create an endpoint to receive incoming webhooks

β†’ This endpoint will act as the receiver for incoming webhooks, enabling us to handle, verify, and validate the webhook data during tests.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
 import express from "express";

const webhooks: any[] = [];

export async function startServer() {
  const app = express();
  const port = 3001;

  app.use(express.json());

  app.post("/webhook", (req, res) => {
    const data = req.body;
    webhooks.push(data);
    res.status(200).send("OK");
  });

  const server = app.listen(port, () => {
    console.log(`Server listening on http://localhost:${port}`);
  });

  return {
    server,
    getWebhooks: () => [...webhooks],
    clearWebhooks: () => webhooks.splice(0)
  };
}

πŸš€ Starts a local Express server on port 3001 that listens for incoming POST requests to the /webhook endpoint.
πŸ—„οΈ Stores received webhook payloads in memory and provides handy utility functions to access or clear them.

🌐 Expose your local server using ngrok

β†’ Public URL lets external services reach local endpoint.

πŸ’‘ Note:
Example in step 1 will work just fine as long as webhooks are coming from the local machine.
If you want to work with external systems β€” you need to open your endpoint to the internet.
That’s where ngrok gets you covered πŸ’» β†’ 🀝 β†’ 🌐

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 import ngrok from "ngrok";

. . .
const server = app.listen(port, () => {
    console.log(`Server listening on port ${port}`);
  });

  let url = await ngrok.connect(port);
    console.log(`ngrok tunnel opened at: ${url}`);

  return {
    server,
    url,
    getWebhooks: () => [...webhooks],
. . .

🧾 Write a smart parser

β†’ Extract the status or any required data from incoming webhook
β†’ Store it in a variable for your test to check later.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
  interface WebhookData {
  data?: {
    status?: string;
    [key: string]: unknown;
  };
  [key: string]: unknown;
}

const webhooks: WebhookData[] = [];

export async function waitForWebhookStatuses(
  expectedStatuses: string[],
  timeout = 5,
): Promise<WebhookData[]> {
  const start = Date.now();
  const loggedStatuses = new Set<string>();

  return new Promise((resolve, reject) => {
    const interval = setInterval(() => {
      const statuses = webhooks.map((w) => w.status).filter((s): s is string => Boolean(s));

      for (const status of statuses) {
        if (!loggedStatuses.has(status)) {
          console.log(`Webhook received with status: "${status}"`);
          loggedStatuses.add(status);
        }
      }

      const allReceived = expectedStatuses.every((s) => statuses.includes(s));

      if (allReceived) {
        clearInterval(interval);
        resolve([...webhooks]);
      }

      const timeoutMs = timeout * 1000 * 60;
      if (Date.now() - start > timeoutMs) {
        clearInterval(interval);
        reject(new Error(`Timeout: not all statuses received. Got: ${statuses}`));
      }
    }, 500);
  });
}

⏳ Waits for webhook events with the specified statuses to arrive within the given time limit.

NOTE
Don’t forget to add it to return in startServer()
1
2
3
4
5
6
7
8
9
 . . . 
return {
    server,
    url,
    getWebhooks: () => [...webhooks],
    clearWebhooks: () => webhooks.splice(0),
    waitForWebhookStatuses,
  };
. . .

🧹 Clean up after test

β†’ Add a function to stop both the local server and the ngrok tunnel
β†’ Prevent leftover processes messing up next runs.

1
2
3
4
5
  export async function stopServer(server: Server) {
  server.close();
  await ngrok.disconnect();
  await ngrok.kill();
}

Now put it to the test πŸ§ͺ

Example spec that receives and validates webhooks πŸ₯³

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import test, { expect } from '@playwright/test';
import { startServer, stopServer } from '../../utils/webhook/ngrok-client';
import { buildDeliveryWebhook } from '../../utils/webhook/webhook-builder';

let serverInstance: Awaited<ReturnType<typeof startServer>>;
let webhookUrl: string;

// Start the webhook server before all tests
test.beforeAll(async () => {
  serverInstance = await startServer();
  webhookUrl = serverInstance.url + "/webhook";
});

// Stop the webhook server after all tests
test.afterAll(async () => {
  await stopServer(serverInstance.server);
});

test.describe("Webhook status flow", () => {
  test("should receive all expected webhook statuses", async () => {
    test.setTimeout(300_000);
    const expectedStatuses = ["created", "cooking", "delivering", "delivered"];

    // Start waiting for all expected webhook statuses in background
    const hooksPromise = serverInstance.waitForWebhookStatuses(expectedStatuses, 5);

    // Send webhooks with expected statuses sequentially
    await sendWebhookWithCustomStatus(webhookUrl, expectedStatuses);

    // Await until all expected statuses are received or timeout occurs
    const hooks = await hooksPromise;

    // Extract received statuses from webhooks
    const receivedStatuses = hooks.map(hook => hook.status);

    // Assert that all expected statuses were received
    expect(receivedStatuses).toEqual(expect.arrayContaining(expectedStatuses));
  });
});

Wrapping it all up πŸ€—

Now you know how to handle webhooks in e2e specs! πŸ§ͺπŸš€
You can go further from here and Pimp My Ride 😎 for your project needs:

  • βœ… Add validators for more fields
  • ⏰ Set time deadlines for target statuses
  • πŸ”’ Check correct order of statuses
  • 🚫 Look out for doubles
  • πŸ—ƒοΈ Compare data in DB with webhook
  • ✨ Etc
    οΏΌ
    All of code above is available in my:
    Playwright example project
This post is licensed under CC BY 4.0 by the author.