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.
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 devYou 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-appHORIZON: Tunnel connected
URL https://my-worldline-app.hrzn.run (reserved)
Forwarding http://localhost:3000
Request log https://hrzn.run/dashboard/tunnels/my-worldline-appUse -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
- Log in to the Merchant Portal and go to Developer, then Webhooks.
- Select Generate webhooks keys. The table shows a Webhooks ID and a Secret Webhook Key.
- Copy the secret right away. The portal shows it for 60 seconds only.
- Select Add webhook endpoint, enter
https://my-worldline-app.hrzn.run/api/webhooks/worldlinein the dialog, and select Confirm.
You can add up to five endpoints.
If you already have a Webhooks ID and secret pair, Generate webhooks keys creates a new pair and revokes the existing one immediately.
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.
WORLDLINE_WEBHOOK_KEY_ID=replace-with-your-webhooks-id
WORLDLINE_WEBHOOK_SECRET=replace-with-your-secret-webhook-keyRestart 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:
POST 200 /api/webhooks/worldlineYour app terminal prints:
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:
- The secret.
WORLDLINE_WEBHOOK_SECRETmust be the Secret Webhook Key that matches the key ID inWORLDLINE_WEBHOOK_KEY_ID. If you selected Generate webhooks keys again, the old pair no longer works. - The key ID. The handler rejects a request whose
X-GCS-KeyIddiffers fromWORLDLINE_WEBHOOK_KEY_ID. - The body. Hash the raw body as UTF-8. Don't call
request.json()first. - The restart. Restart
npm run devafter 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
- Read Worldline's webhooks guide.
- Reserve a subdomain so your URL never changes: see pricing.
Test Stripe webhooks locally
Receive Stripe webhook events on localhost with a Horizon tunnel, and verify their signatures in a Next.js route handler.
Test Xero webhooks locally
Receive Xero webhook events on localhost with a Horizon tunnel, pass the Intent to receive check, and verify the x-xero-signature header.