Horizon

Test Contentful webhooks locally

Receive Contentful webhook events on localhost with a Horizon tunnel, and verify the signed request with verifyRequest.

Receive Contentful events on your laptop while you build, with a URL Contentful can reach.

Before you begin

  • Node.js 18 or later
  • A Horizon account and the CLI (see Getting started)
  • A reserved subdomain for -s. Reserve one on the Subdomains page.
  • A Contentful space where you can manage webhooks

Start your app

Contentful can sign webhook requests. It adds the x-contentful-signature, x-contentful-signed-headers and x-contentful-timestamp headers. The signature is an HMAC SHA-256 (a keyed hash) of the method, path, signed headers and body, using your signing secret.

Contentful's verifyRequest helper in @contentful/node-apps-toolkit checks all of it. Install it.

npm install @contentful/node-apps-toolkit

verifyRequest returns true or false. It throws when the request is shaped wrong or older than the time-to-live, which is 30 seconds by default.

app/api/webhooks/contentful/route.ts
import { verifyRequest } from "@contentful/node-apps-toolkit";

export async function POST(request: Request) {
  const secret = process.env.CONTENTFUL_SIGNING_SECRET;
  if (!secret) {
    return new Response("Missing CONTENTFUL_SIGNING_SECRET", { status: 500 });
  }

  const body = await request.text();
  const { pathname, search } = new URL(request.url);

  let isValid = false;
  try {
    isValid = verifyRequest(secret, {
      method: request.method,
      path: `${pathname}${search}`,
      headers: Object.fromEntries(request.headers),
      body,
    });
  } catch (error) {
    console.error("Could not verify request:", error);
  }

  if (!isValid) {
    return new Response("Invalid signature", { status: 401 });
  }

  const topic = request.headers.get("x-contentful-topic");
  console.log(`Received Contentful event: ${topic}`);

  return new Response("ok", { status: 200 });
}

You get the secret in the dashboard step below. Create the file now.

.env.local
CONTENTFUL_SIGNING_SECRET=replace-with-your-signing-secret

Start the app on port 3000.

npm run dev

Start a tunnel

Use -s with a subdomain you reserved. Without it, the subdomain is random and changes every run, so your Contentful webhook would point at a dead URL after a restart. Reserved subdomains are a paid feature, see Pricing.

hrzn tunnel http://localhost:3000 -s my-app
Output
HORIZON: Tunnel connected
  URL          https://my-app.hrzn.run (reserved)
  Forwarding   http://localhost:3000
  Request log  https://hrzn.run/dashboard/tunnels/my-app

Your public URL is https://my-app.hrzn.run. Keep this terminal open.

Turn on request verification

  1. In your space, open Settings, then Webhooks.
  2. Select the Settings tab.
  3. Select Enable request verification.
  4. Copy the signing secret into CONTENTFUL_SIGNING_SECRET and restart npm run dev.

Add the webhook in Contentful

  1. In Settings, Webhooks, select Add Webhook.
  2. Enter a name.
  3. Enter https://my-app.hrzn.run/api/webhooks/contentful as the URL, and select the POST method.
  4. Pick the events that trigger the webhook, for example publishing an entry.
  5. Set the webhook to active and save it.

Trigger an event

Contentful's docs describe no resend button for webhook calls. Trigger a real event instead: publish an entry in the space.

To inspect a call, open the webhook's overview and select View details on an event. It shows the JSON and your server's response. Contentful keeps up to 500 log entries per webhook.

Check it works

Publish an entry. Horizon prints one line for the request:

Output
  POST    200  /api/webhooks/contentful

Your app terminal prints the topic from the X-Contentful-Topic header, in the form ContentManagement.Entry.publish:

Output
Received Contentful event: ContentManagement.Entry.publish

If the line shows [401], see Troubleshooting.

Troubleshooting

The signature doesn't match

  • Check that CONTENTFUL_SIGNING_SECRET is the secret from Enable request verification, and restart npm run dev after you edit .env.local.
  • Pass the raw body to verifyRequest. Don't pass JSON.stringify of a parsed body, because that can change the bytes.
  • Read the error in your app terminal. verifyRequest throws when the request is older than its 30-second time-to-live. Check your computer's clock.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Restart with -s my-app and the URL stays the same. -s needs a subdomain you reserved, see Pricing.

Nothing reaches your app

  • Check that the Horizon terminal is still running. If its last line is Connection lost. Reconnecting…, wait for Reconnected.
  • Check that the webhook is active and that its URL ends with /api/webhooks/contentful.

Next steps

On this page