Horizon

Test Worldline webhooks locally

Receive Worldline Direct webhook events on localhost with a Horizon tunnel, and verify the X-GCS-Signature header in a Next.js route handler.

Receive Worldline events on your laptop while you build, with a public HTTPS URL Worldline can reach.

Horizon has no Worldline integration. Worldline sends webhooks to a public URL, and Horizon provides that URL.

This guide covers Worldline Direct and its Merchant Portal.

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 Worldline Direct test account with access to the Merchant Portal
  • A Next.js app that uses the App Router and runs on port 3000

Start your app

Worldline signs the request body with HMAC-SHA-256 (a keyed hash), keyed with your webhooks secret key. It base64 encodes the result and sends it in the X-GCS-Signature header. The X-GCS-KeyId header names the webhooks key that signed the message. Worldline's webhooks guide says its server SDKs do the verification. The Node.js SDK documentation and the published package disagree about the webhooks API, so the handler does the check by hand with node:crypto. The steps are the ones Worldline documents.

Read the raw body with request.text() before you parse it, and compare with timingSafeEqual.

app/api/webhooks/worldline/route.ts
import { createHmac, timingSafeEqual } from "node:crypto";

export async function POST(request: Request) {
  const keyId = process.env.WORLDLINE_WEBHOOK_KEY_ID;
  const secret = process.env.WORLDLINE_WEBHOOK_SECRET;
  if (!keyId || !secret) {
    return new Response("Missing Worldline webhook settings", { status: 500 });
  }

  const body = await request.text();
  const received = request.headers.get("x-gcs-signature") ?? "";
  const receivedKeyId = request.headers.get("x-gcs-keyid");
  const expected = createHmac("sha256", secret).update(body, "utf8").digest("base64");

  const receivedBuffer = Buffer.from(received);
  const expectedBuffer = Buffer.from(expected);
  const isValid =
    receivedKeyId === keyId &&
    receivedBuffer.length === expectedBuffer.length &&
    timingSafeEqual(receivedBuffer, expectedBuffer);

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

  const event = JSON.parse(body) as { type: string; id: string };
  console.log(`Received Worldline event: ${event.type} (${event.id})`);

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

Worldline wants a 2xx response for every event. Answer first and do slow work afterwards, or Worldline assumes the delivery failed and retries.

Start the app:

npm run dev

You add the key ID and secret in a later step.

Start a tunnel

In a second terminal, open a tunnel to port 3000 on a subdomain you reserved, with -s:

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

Use -s. Without it, the subdomain is random and changes on every run, and you would have to edit the endpoint in the Merchant Portal each time you restart. Reserved subdomains are a paid feature, see Pricing.

Generate the webhooks key and add the endpoint

  1. Log in to the Merchant Portal and go to Developer, then Webhooks.
  2. Select Generate webhooks keys. The table shows a Webhooks ID and a Secret Webhook Key.
  3. Copy the secret right away. The portal shows it for 60 seconds only.
  4. Select Add webhook endpoint, enter https://my-worldline-app.hrzn.run/api/webhooks/worldline in the dialog, and select Confirm.

You can add up to five endpoints.

Verify the signature

Add the Webhooks ID and the secret to .env.local. The Webhooks ID is the value Worldline sends in X-GCS-KeyId.

.env.local
WORLDLINE_WEBHOOK_KEY_ID=replace-with-your-webhooks-id
WORLDLINE_WEBHOOK_SECRET=replace-with-your-secret-webhook-key

Restart npm run dev so Next.js loads the new variables.

Check it works

Ask Worldline to send a test message with the SendTestWebhook endpoint of the Direct API. Send {"url": "https://my-worldline-app.hrzn.run/api/webhooks/worldline"} as the body, with the {merchantId} of your account in the path. If you leave url empty, Worldline uses the endpoint you added in the Merchant Portal. The call needs the API authentication described in Worldline's API documentation.

If your endpoint answers with a 2xx status, Worldline returns 204 to your request.

The Horizon terminal prints one line for the request:

Output
  POST    200  /api/webhooks/worldline

Your app terminal prints:

Output
Received Worldline event: payment.test (a028fc60-b04c-4119-8c87-b836967e30de)

The test message has the type payment.test. The ID differs on every call.

Worldline also offers a ValidateWebhookCredentials endpoint. It checks your webhooks key and secret against your account, and it doesn't call your server.

Troubleshooting

The signature doesn't match

The Horizon line shows [401]. Check these in order:

  1. The secret. WORLDLINE_WEBHOOK_SECRET must be the Secret Webhook Key that matches the key ID in WORLDLINE_WEBHOOK_KEY_ID. If you selected Generate webhooks keys again, the old pair no longer works.
  2. The key ID. The handler rejects a request whose X-GCS-KeyId differs from WORLDLINE_WEBHOOK_KEY_ID.
  3. The body. Hash the raw body as UTF-8. Don't call request.json() first.
  4. The restart. Restart npm run dev after you edit .env.local.

The request returns 404

The Horizon line shows [404]. The route file app/api/webhooks/worldline/route.ts serves /api/webhooks/worldline. Check the endpoint URL for typos, and make sure the file exports POST.

The URL changed after a restart

You started the tunnel without -s, so Horizon gave you a new random subdomain. Worldline still sends events to the old URL. Restart with -s, and edit the endpoint in Developer, Webhooks if the URL differs.

Worldline sends the same event again

Worldline retries an event five times when it gets no 2xx response. The gaps are 10 minutes, 1 hour, 2 hours, 8 hours and 24 hours after the last attempt. Each retry carries a retry-count header, which is 0 on the first attempt. Duplicates have the same payment.id and type, so process each event idempotently.

Next steps

On this page