Webhooks

Handling Webhooks

Use SDK-provided webhook handlers for Next.js, Hono, and Express to receive and process Whatalo events with built-in signature verification.

@whatalo/plugin-sdk ships framework-specific webhook handlers that handle signature verification, JSON parsing, and event routing for you. Pick the adapter that matches your server framework.

Next.js (App Router)

// app/api/webhooks/route.ts
import { createWebhookHandler } from "@whatalo/plugin-sdk/adapters/nextjs";

export const POST = createWebhookHandler({
  secret: process.env.WHATALO_CLIENT_SECRET!,
  handlers: {
    "order.created": async (payload, context) => {
      console.log("New order:", payload.order.id);
      console.log("Delivery:", context.deliveryId);
      // Trigger fulfilment, send confirmation email, etc.
    },
    "order.updated": async (payload) => {
      console.log("Status changed:", payload.order.status);
    },
    "product.updated": async (payload) => {
      console.log("Product changed:", payload.product.id);
      // Sync with your catalogue cache
    },
  },
  onUnhandledEvent: async (event, payload, context) => {
    // Called for any event not listed in handlers above
    console.log(`Unhandled event: ${event}`, context.deliveryId);
  },
});

The handler reads the raw request body before parsing, which is required for correct HMAC verification. Do not wrap this route in any body-parsing middleware.

Hono

import { Hono } from "hono";
import { createWebhookHandler } from "@whatalo/plugin-sdk/adapters/hono";

const app = new Hono();

const handler = createWebhookHandler({
  secret: process.env.WHATALO_CLIENT_SECRET!,
  handlers: {
    "order.created": async (payload) => {
      await processNewOrder(payload.order);
    },
    "customer.created": async (payload) => {
      await syncCustomer(payload.customer);
    },
  },
});

app.post("/api/webhooks", handler);

export default app;

Express

import express from "express";
import { createWebhookHandler } from "@whatalo/plugin-sdk/adapters/express";

const app = express();

const handler = createWebhookHandler({
  secret: process.env.WHATALO_CLIENT_SECRET!,
  handlers: {
    "order.created": async (payload) => {
      await processNewOrder(payload.order);
    },
  },
});

// The adapter verifies the signature over the raw body.
// Do not use express.json() on this route.
app.post("/api/webhooks", express.raw({ type: "application/json" }), handler);

If you use express.json() globally, mount the raw webhook route before the global parser:

app.post("/api/webhooks", express.raw({ type: "application/json" }), handler);
app.use(express.json());

Handler Options

OptionTypeRequiredDescription
secretstringYesYour plugin's client secret, used for HMAC signature verification
handlersobjectYesMap of event type string → async handler function
onUnhandledEventfunctionNoCatch-all called for events not in handlers

Handler Signature

Each handler receives a typed payload plus delivery metadata from the request headers:

type WebhookPayload = {
  event_id?: string;       // Public entity ID for business-level deduplication
  occurred_at?: string;    // ISO 8601 event timestamp
  store: { id: string; name?: string; timezone: string };
  order?: unknown;         // Present for order events
  product?: unknown;       // Present for product events
  customer?: unknown;      // Present for customer events
};

type WebhookHandlerContext = {
  deliveryId: string; // X-Webhook-Id
  event: string;      // X-Webhook-Event
  timestamp: string;  // X-Webhook-Timestamp
};

The event name itself is not in the body. The adapter reads X-Webhook-Event to select the matching handler and passes it as context.event.

context.deliveryId is unique per delivery attempt and should be used for delivery idempotency. For order, customer, and product business-level deduplication, combine context.event with payload.event_id; that event_id matches the public entity ID in payload.order.id, payload.customer.id, or payload.product.id.

Error Handling in Handlers

If a handler throws an error, the adapter catches it and returns 500 to trigger a Whatalo retry. If you need diagnostic details, log them inside your handler before throwing. To silently ignore an event without triggering a retry, return normally without throwing:

"order.created": async (payload) => {
  const order = payload.order as OrderPayload;

  // If the order is already in our system, skip silently
  const exists = await db.orders.findUnique({ where: { id: order.id } });
  if (exists) return; // Returns 200 OK — no retry

  // Process the new order
  await db.orders.create({ data: mapOrder(order) });
},

Manual Verification (No Framework Adapter)

If you are using a framework not listed here, verify signatures manually using verifyWebhook:

import { verifyWebhook } from "@whatalo/plugin-sdk/webhooks";

// rawBody must be the raw string body — read it before JSON.parse
const isValid = verifyWebhook({
  payload: rawBody,
  signature: req.headers["x-webhook-signature"] as string,
  timestamp: req.headers["x-webhook-timestamp"] as string,
  secret: process.env.WHATALO_CLIENT_SECRET!,
});

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

const payload = JSON.parse(rawBody);
const event = req.headers["x-webhook-event"];

See Verification & Security for full details on the signature algorithm and replay protection.

On this page