# Subscribe to Webhook Events

By
The Quable Team

Webhook handling
1. Register webhook events
2. Listen to webhook events
3. HMAC signature verification



# Webhook handling

Webhooks are HTTP requests sent by the Quable PIM to your application to inform it about events in real-time. This enables seamless communication between your app and the PIM, allowing you to trigger actions as events occur (e.g., a document is created or updated).


# Requirements

We'll use the @quable/quable-pim-js library to register webhooks via the Quable PIM API.


# 1. Register webhook events

# Define webhook events

Open the quable.app.yml file at the root of your project and add the webhook events you want to listen to:

application_type: in_app
quable_pim_scope:
  - full_access
webhook_events:
  - document.create
  - document.update

Available webhook events include:

  • document.create — Triggered when a new document is created.
  • document.update — Triggered when an existing document is updated.

Note: A maximum of 5 events can be registered per webhook.


# Register webhooks during installation

Update the install route (src/app/install/route.ts) to automatically register webhooks when the app is installed:

import prisma from "@/lib/prisma";
import { QuablePimClient } from "@quable/quable-pim-js";
import { NextResponse } from "next/server";
import { readFile } from "fs/promises";
import * as yaml from "js-yaml";
import { join } from "path";

interface QuableAppConfig {
  application_type: string;
  quable_pim_scope: string[];
  webhook_events?: string[];
}

async function getAppConfig(): Promise<QuableAppConfig> {
  const filePath = join(process.cwd(), "quable.app.yml");
  const yamlContent = await readFile(filePath, "utf8");
  return yaml.load(yamlContent) as QuableAppConfig;
}

async function registerWebhooks(
  pimClient: QuablePimClient,
  events: string[]
): Promise<void> {
  const url = `${process.env.NEXT_PUBLIC_APP_URL}/webhook`;
  const name = "Quable App Template - Webhook";

  const payload = {
    active: true,
    name,
    url,
    events,
  };

  const existingWebhooks = await pimClient.API.REST.Webhook.getAll({
    url,
    name,
  });

  if (existingWebhooks?.length > 0) {
    await pimClient.API.REST.Webhook.update(existingWebhooks[0].id, payload);
  } else {
    await pimClient.API.REST.Webhook.create(payload);
  }
}

export async function POST(request: Request): Promise<NextResponse> {
  try {
    const requestPayload = await request.json();
    const { quableInstanceName, quableAuthToken } = requestPayload.data;

    const pimClient = new QuablePimClient({
      apiToken: quableAuthToken,
      instanceName: quableInstanceName,
    });

    // Validate the token
    await pimClient.API.REST.User.getAll({ limit: 1, type: "api" });

    // Store or update the instance
    await prisma.quableInstance.upsert({
      where: { name: quableInstanceName },
      create: { name: quableInstanceName, token: quableAuthToken },
      update: { name: quableInstanceName, token: quableAuthToken },
    });

    // Register webhooks from quable.app.yml
    const appConfig = await getAppConfig();
    if (appConfig.webhook_events?.length) {
      await registerWebhooks(pimClient, appConfig.webhook_events);
    }

    return NextResponse.json(
      { message: `QuableApp installed on ${quableInstanceName}.quable.com` },
      { status: 200 }
    );
  } catch (error) {
    console.error("Installation error:", error);
    return NextResponse.json(
      { message: "Installation failed. Verify your credentials and try again." },
      { status: 500 }
    );
  }
}

The registerWebhooks function:

  1. Builds the webhook URL pointing to your app's /webhook endpoint.
  2. Checks if a webhook with the same URL and name already exists.
  3. Updates the existing webhook or creates a new one.

# 2. Listen to webhook events

Create an API route to receive webhook events. Create the file src/app/webhook/route.ts:

import { NextResponse } from "next/server";

export async function POST(request: Request): Promise<NextResponse> {
  try {
    const payload = await request.json();

    console.log("Webhook received:", payload);

    // Process the event based on its type
    switch (payload.code) {
      case "document.create":
        await handleDocumentCreate(payload);
        break;
      case "document.update":
        await handleDocumentUpdate(payload);
        break;
      default:
        console.log("Unhandled webhook event:", payload.code);
    }

    return NextResponse.json({}, { status: 200 });
  } catch (error) {
    console.error("Webhook processing error:", error);
    return NextResponse.json(
      { error: "Webhook processing failed" },
      { status: 500 }
    );
  }
}

async function handleDocumentCreate(payload: WebhookPayload) {
  const { resource } = payload;
  console.log(`Document created: ${resource.code} (type: ${resource.document_type_code})`);
  // Add your business logic here
}

async function handleDocumentUpdate(payload: WebhookPayload) {
  const { resource } = payload;
  console.log(`Document updated: ${resource.code} (type: ${resource.document_type_code})`);
  // Add your business logic here
}

interface WebhookPayload {
  uuid: string;
  code: string;
  links: { rel: string; href: string }[];
  created_at: string;
  created_by: string;
  resource: {
    code: string;
    type: string;
    document_type_code: string;
    links: { rel: string; href: string }[];
    locales: {
      updated: string[];
      inherited: string[];
    };
    updated_items: string[];
  };
  process: {
    correlationId: string;
  };
}

# Webhook payload format

When a webhook event is triggered, the PIM sends a POST request with the following payload structure:

{
  "uuid": "0963114b-c3d9-4a5e-9a33-ccc493b73649",
  "code": "document.update",
  "links": [
    {
      "rel": "self",
      "href": "/api/events/0963114b-c3d9-4a5e-9a33-ccc492b73629"
    }
  ],
  "created_at": "2023-11-03T08:58:00+00:00",
  "created_by": "quable",
  "resource": {
    "code": "vans-sh-8-hi",
    "type": "document",
    "document_type_code": "product",
    "links": [{ "rel": "self", "href": "/api/documents/vans-sh-8-hi" }],
    "locales": {
      "updated": ["en_US"],
      "inherited": ["fr_FR"]
    },
    "updated_items": []
  },
  "process": {
    "correlationId": "bca5bc9d-6250-4049-8o22-7bfd2f9ce9f1"
  }
}
Field Description
uuid Unique event identifier
code Event type (e.g., document.create, document.update)
created_at Timestamp of the event
created_by User or system that triggered the event
resource.code Code of the affected resource (e.g., document code)
resource.type Type of resource (document, etc.)
resource.document_type_code Document type code (e.g., product)
resource.locales.updated Locales that were modified
process.correlationId ID linking related events

# 3. HMAC signature verification

All requests sent from Quable PIM to your application can be authenticated using HMAC-SHA256. The PIM sends two custom headers:

  • X-Timestamp — Unix timestamp of the request time.
  • X-Signature — HMAC signature encoded in base64.

Add signature verification to your webhook route:

import { NextResponse } from "next/server";
import { createHmac, timingSafeEqual } from "crypto";

const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || "";
const TIMESTAMP_TOLERANCE = 300; // 5 minutes in seconds

function verifySignature(
  method: string,
  endpoint: string,
  timestamp: string,
  payload: string,
  receivedSignature: string
): boolean {
  // Check timestamp validity
  const currentTime = Math.floor(Date.now() / 1000);
  const timeDiff = Math.abs(currentTime - parseInt(timestamp, 10));
  if (timeDiff > TIMESTAMP_TOLERANCE) {
    return false;
  }

  // Build the string to sign: METHOD|ENDPOINT|TIMESTAMP|PAYLOAD
  const stringToSign = `${method.toUpperCase()}|${endpoint}|${timestamp}|${payload}`;

  // Calculate expected HMAC-SHA256
  const expectedSignature = createHmac("sha256", WEBHOOK_SECRET)
    .update(stringToSign)
    .digest("base64");

  // Constant-time comparison to prevent timing attacks
  try {
    return timingSafeEqual(
      Buffer.from(expectedSignature),
      Buffer.from(receivedSignature)
    );
  } catch {
    return false;
  }
}

export async function POST(request: Request): Promise<NextResponse> {
  const timestamp = request.headers.get("X-Timestamp") || "";
  const signature = request.headers.get("X-Signature") || "";
  const body = await request.text();

  // Verify HMAC signature
  const endpoint = `${process.env.NEXT_PUBLIC_APP_URL}/webhook`;
  if (!verifySignature("POST", endpoint, timestamp, body, signature)) {
    return NextResponse.json(
      { error: "Invalid signature" },
      { status: 401 }
    );
  }

  // Parse and process the verified payload
  const payload = JSON.parse(body);

  switch (payload.code) {
    case "document.create":
      await handleDocumentCreate(payload);
      break;
    case "document.update":
      await handleDocumentUpdate(payload);
      break;
    default:
      console.log("Unhandled webhook event:", payload.code);
  }

  return NextResponse.json({}, { status: 200 });
}

Add the webhook secret to your .env file:

WEBHOOK_SECRET="your-shared-secret-from-quable"

# Re-install to register webhooks

After updating your quable.app.yml with webhook events and deploying your changes, you need to uninstall and reinstall the app from your partner portal dashboard to register the webhooks.

Once a document is created or updated in the PIM, your /webhook endpoint will receive the event payload.