Skip to main content
A copy-paste-runnable webhook receiver in Node + Express. Around 80 lines. Demonstrates the four things you have to get right: raw-body capture, constant-time signature compare, idempotency on x-klikit-event-id, and fast 2xx acknowledgement.
The source also lives in the partner-api repo at examples/webhook-receiver/node so you can clone it directly.

Run it

npm install
KLIKIT_WEBHOOK_SECRET=<your secret> node server.js
Listens on :8080 and exposes POST /webhooks/klikit.

Source

server.js
// Klikit webhook receiver — minimal reference (Node + Express).
const crypto = require("crypto");
const express = require("express");

const PORT = process.env.PORT || 8080;
const SECRET = process.env.KLIKIT_WEBHOOK_SECRET;
if (!SECRET) {
  console.error("KLIKIT_WEBHOOK_SECRET not set");
  process.exit(1);
}

const app = express();

// IMPORTANT: capture the raw body BEFORE JSON parsing. The HMAC is computed
// over the bytes klikit sent — any reformatting (key reordering, whitespace
// changes) will break verification.
app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf; },
}));

// Replace with your own persistent store (Redis SETNX, Postgres unique index, etc).
const seenEventIDs = new Set();

app.post("/webhooks/klikit", (req, res) => {
  const sigHeader = req.get("x-klikit-signature");
  const eventID = req.get("x-klikit-event-id");
  const eventType = req.get("x-klikit-event-type");

  if (!sigHeader || !eventID || !eventType) {
    return res.status(400).json({ error: "missing klikit headers" });
  }

  const expected = crypto
    .createHmac("sha256", SECRET)
    .update(req.rawBody)
    .digest("hex");

  const ok =
    sigHeader.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sigHeader), Buffer.from(expected));

  if (!ok) return res.status(401).json({ error: "invalid signature" });

  // Idempotency — klikit retries on non-2xx, so the same event_id may arrive
  // more than once. Drop duplicates silently with a 2xx.
  if (seenEventIDs.has(eventID)) {
    return res.status(200).json({ status: "duplicate-ignored" });
  }
  seenEventIDs.add(eventID);

  // Acknowledge fast. Do the actual work asynchronously so klikit's delivery
  // timeout (10s) isn't tied to your downstream processing.
  res.status(200).json({ status: "received" });

  setImmediate(() => handleEvent(eventType, req.body));
});

function handleEvent(eventType, payload) {
  const { brand_id, branch_id, orders } = payload;
  console.log(`[${eventType}] brand=${brand_id} branch=${branch_id} orders=${orders?.length ?? 0}`);

  switch (eventType) {
    case "klikit.order.created.v2":      /* push to POS */          break;
    case "klikit.order.status.updated":  /* update order record */  break;
    case "klikit.order.cart.updated":    /* replace cart state */   break;
    default: console.warn(`unknown event type: ${eventType}`);
  }
}

app.listen(PORT, () => console.log(`klikit webhook receiver listening on :${PORT}`));

Hardening for production

The reference above runs as-is, but two things need real-world replacements before you put it in production:
  • seenEventIDs is in-memory. Replace with Redis (SET key NX EX 86400) or a Postgres unique index. An in-memory set evaporates on every restart, letting duplicates back in.
  • setImmediate is a single-process queue. For real throughput, push to a durable queue (SQS, Pub/Sub, RabbitMQ, BullMQ) and process from a separate worker so an OOM in your processor can’t drop events.