Webhook events and delivery

Bluestone PIM can send webhook messages to an external HTTP endpoint when subscribed PIM events occur. Use webhooks to react to product, category, asset, and Public API sync changes without polling.

Webhook delivery order is not guaranteed. Treat each webhook as a signal that something changed. If your workflow needs the latest state, use the event to trigger a Management API read for the affected entity instead of relying only on the webhook payload.

Event delivery model

Bluestone PIM delivers events through webhook push: when a subscribed event occurs, Bluestone PIM sends an HTTP POST request to your registered endpoint in near-real-time. This is the supported mechanism for receiving real-time change notifications without polling the Management API.

Bluestone PIM does not provide Server-Sent Events (SSE) or WebSocket streaming. Use webhook subscriptions for all real-time, push-based, change-driven workflows. When you need a continuous live stream rather than discrete change events (for example, a dashboard that always shows current state), subscribe to webhooks and use each event to trigger a Management API read for the affected entity.

For high-volume or batch reads, combine webhooks with the cursor pagination and async patterns in Build reliable AI agents on Bluestone PIM rather than polling.

Endpoints

All endpoints below require OAuth2 bearer authentication and use the External Notifications API.

TaskMethod and pathReference
Create webhookPOST /notification-external/webhooksCreate webhook
Search webhooksPOST /notification-external/webhooks/listSearch webhook
Subscribe to event typesPUT /notification-external/subscriptions/webhook/{webhookId}/eventsSubscribe for given events for given webhook
List webhook event subscriptionsGET /notification-external/subscriptions/webhook/{webhookId}/eventsGet all events subscriptions enabled for given webhook
Unsubscribe from event typesDELETE /notification-external/subscriptions/webhook/{webhookId}/eventsUnsubscribe for given events for given webhook
Ping a webhook URLPOST /notification-external/messages/webhook?url={url}&secret={secret}Ping webhook
Inspect webhook messagesPOST /notification-external/messages/webhook/listGet messages for a given webhook

Create and subscribe

A webhook has an active flag, a url, and a secret. Bluestone PIM sends webhook messages as HTTP POST requests to the URL. The secret is used to sign each message.

Set BLUESTONE_API_BASE and BLUESTONE_AUTH_BASE from the Environments page.

BLUESTONE_AUTH_BASE="https://idp.test.bluestonepim.com/op/token"
BLUESTONE_API_BASE="https://api.test.bluestonepim.com"
CLIENT_ID="YOUR_CLIENT_ID"
CLIENT_SECRET="YOUR_CLIENT_SECRET"
WEBHOOK_URL="https://example.ngrok-free.app/webhooks/bluestone"
WEBHOOK_SECRET="LongAndSecretPassword"

ACCESS_TOKEN=$(
  curl -sS -X POST "$BLUESTONE_AUTH_BASE" \
    -H "Content-Type: application/x-www-form-urlencoded" \
    --data-urlencode "grant_type=client_credentials" \
    --data-urlencode "client_id=$CLIENT_ID" \
    --data-urlencode "client_secret=$CLIENT_SECRET" |
  jq -r ".access_token"
)

curl -sS -X POST "$BLUESTONE_API_BASE/notification-external/webhooks" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"active\": true,
    \"url\": \"$WEBHOOK_URL\",
    \"secret\": \"$WEBHOOK_SECRET\"
  }"

The create call returns HTTP 201 with an empty body. Retrieve the webhook id by calling the search endpoint and matching on your URL. Use that ID to subscribe the webhook to event types.

WEBHOOK_ID="YOUR_WEBHOOK_ID"

curl -sS -X PUT "$BLUESTONE_API_BASE/notification-external/subscriptions/webhook/$WEBHOOK_ID/events" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "eventTypes": [
      "PRODUCT_CREATED",
      "PRODUCT_WATCH_ATTRIBUTE_UPDATE_VALUE",
      "PRODUCT_SYNC_DONE"
    ]
  }'

List the event types currently enabled for a webhook:

curl -sS "$BLUESTONE_API_BASE/notification-external/subscriptions/webhook/$WEBHOOK_ID/events" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Event type list

Subscribe only to the event types your integration needs. The subscription request body is:

{
  "eventTypes": ["PRODUCT_CREATED", "PRODUCT_SYNC_DONE"]
}

Supported webhook event type values:

FamilyEvent types
Product lifecycle and syncPRODUCT_CREATED, PRODUCT_SYNC_DONE
Product assets, bundle, category, label, relation, state, variantPRODUCT_WATCH_ASSET, PRODUCT_WATCH_BUNDLE, PRODUCT_WATCH_BUNDLE_QUANTITY, PRODUCT_WATCH_CATEGORY, PRODUCT_WATCH_LABEL, PRODUCT_WATCH_RELATION, PRODUCT_WATCH_STATE, PRODUCT_WATCH_VARIANT
Product metadataPRODUCT_WATCH_METADATA_NAME, PRODUCT_WATCH_METADATA_NUMBER, PRODUCT_WATCH_METADATA_DESCRIPTION
Product attributesPRODUCT_WATCH_ATTRIBUTE, PRODUCT_WATCH_ATTRIBUTE_UPDATE_VALUE, PRODUCT_WATCH_ATTRIBUTE_ASSOCIATION, PRODUCT_WATCH_ATTRIBUTE_DISASSOCIATION
Category lifecycle and structureCATEGORY_CREATED, CATEGORY_REMOVED, CATEGORY_WATCH_ARCHIVE_STATE, CATEGORY_WATCH_MOVE, CATEGORY_WATCH_ORDER
Category metadata and assetsCATEGORY_WATCH_METADATA_NAME, CATEGORY_WATCH_METADATA_NUMBER, CATEGORY_WATCH_METADATA_DESCRIPTION, CATEGORY_WATCH_ASSET
Category attributesCATEGORY_WATCH_ATTRIBUTE, CATEGORY_LOCAL_WATCH_ATTRIBUTE_ASSOCIATION, CATEGORY_LOCAL_WATCH_ATTRIBUTE_DISASSOCIATION, CATEGORY_LOCAL_WATCH_ATTRIBUTE_UPDATE_VALUE
Asset lifecycle and metadataASSET_CREATED, ASSET_WATCH_METADATA_NAME, ASSET_WATCH_METADATA_NUMBER, ASSET_WATCH_METADATA_DESCRIPTION
Asset attributesASSET_WATCH_ATTRIBUTE_UPDATE_VALUE, ASSET_WATCH_ATTRIBUTE_ASSOCIATION, ASSET_WATCH_ATTRIBUTE_DISASSOCIATION

The Help Center also includes payload examples for common product and category event types: Webhook event types.

Payload shape

Webhook messages use a common JSON envelope with a millisecond timestamp and one or more events. Each event contains a changes object with an eventType, one or more entityIds, and an event-family-specific payload.

{
  "timestamp": 1698151920627,
  "events": [
    {
      "changes": {
        "eventType": "PRODUCT_CREATED",
        "entityIds": ["6537bdebb388e74a0482e761"],
        "metadataChanges": [
          {
            "field": "NAME",
            "oldValue": null,
            "newValue": "Product A",
            "context": "en"
          },
          {
            "field": "NUMBER",
            "oldValue": null,
            "newValue": "product-a",
            "context": "en"
          },
          {
            "field": "TYPE",
            "oldValue": null,
            "newValue": "SINGLE",
            "context": "en"
          }
        ]
      }
    }
  ]
}

Product attribute changes include context fields that help consumers understand global and context-aware attributes:

{
  "timestamp": 1698151295719,
  "events": [
    {
      "changes": {
        "eventType": "PRODUCT_WATCH_ATTRIBUTE_UPDATE_VALUE",
        "entityIds": ["641314b6ee358800012b279b"],
        "attributeChange": {
          "changeType": "UPDATE",
          "attributeType": "text",
          "attributeId": "5da7254de21b84000c6ed075",
          "attributeOldValue": "Red",
          "attributeNewValue": "Blue",
          "context": "en",
          "contextId": null,
          "affectedContextIds": ["en", "no", "pl"]
        }
      }
    }
  ]
}

Public API sync events identify the sync session:

{
  "timestamp": 1698152131200,
  "events": [
    {
      "changes": {
        "eventType": "PRODUCT_SYNC_DONE",
        "entityIds": [],
        "syncDoneData": {
          "field": "PAPI_SYNC_ID",
          "value": "6537bebdfbde8a0013911d23",
          "context": "en"
        }
      }
    }
  ]
}

Category events use the same envelope:

{
  "timestamp": 1698225320204,
  "events": [
    {
      "changes": {
        "eventType": "CATEGORY_CREATED",
        "entityIds": ["6538dca57566616af2206608"],
        "name": "Shoes",
        "number": "6538dca57566616af2206608",
        "parentId": "633d67fed6018000014074a1"
      }
    }
  ]
}

Field reference

FieldWhereDescription
timestampWebhook payloadUnix timestamp in milliseconds for the webhook payload.
eventsWebhook payloadArray of event records delivered in the message.
events[].changes.eventTypeWebhook payloadThe event type value, such as PRODUCT_CREATED or PRODUCT_WATCH_ATTRIBUTE_UPDATE_VALUE.
events[].changes.entityIdsWebhook payloadIDs of the affected product, category, asset, or other entity.
events[].changes.*Webhook payloadEvent-family-specific payload/body details, such as metadataChanges, attributeChange, syncDoneData, stateChange, or parentChange.
contextPayload body when relevantLegacy context label, usually a language-like value such as en or no.
contextIdAttribute payloads when relevantThe context where the change was applied. For global attributes, this can be null.
affectedContextIdsAttribute payloads when relevantContexts whose effective value may be impacted by the change.
idWebhookMessageResponse from Get messagesMessage ID for a stored webhook message. Use this message ID when deduplicating messages from the delivery log.
createdAtWebhookMessageResponse from Get messagesMessage creation timestamp.
statusWebhookMessageResponse from Get messagesMessage status: TO_BE_SENT, SENT, ERROR, WEBHOOK_INACTIVE, or IN_PROGRESS.
webhookIdWebhookMessageResponse from Get messagesThe webhook that received or attempted to receive the message.
callsWebhookMessageResponse from Get messagesDelivery attempts, including request payload and subscriber response details.
Organization/customer contextWebhook configurationThe webhook payload examples do not include a separate organization ID field. If one receiver handles multiple customers, map each unique receiver URL, route, or webhookId to the correct customer organization in your integration.

The event payload examples do not show a separate event ID inside events[]. For idempotent processing, use the message ID from Get messages when replaying or reconciling the message log. During live delivery, deduplicate with a deterministic key such as a SHA-256 hash of the raw request body plus each event index, then store the processed entity ID, event type, and timestamp.

Delivery behavior

Return a successful 2xx response only after the message is safely accepted by your receiver. Store the raw body, validate the signature, enqueue internal processing if needed, and respond quickly.

Retry behavior depends on your receiver's HTTP response:

Receiver responseDelivery behavior
429 or 503Bluestone PIM reads your Retry-After response header and retries after that delay. If Retry-After is missing, delivery is not retried for that response.
401 or 403Delivery is not retried. Use these statuses only for permanent authentication or authorization failure.
Other non-success statusesDelivery is retried two times with a ten-minute interval.

If the total number of failed delivery attempts for a webhook exceeds a threshold, the webhook can be blocked for a while. The threshold and blocking time can vary between environments.

Inspect message delivery with POST /notification-external/messages/webhook/list. You can filter by status, such as SENT or ERROR, and review message content plus subscriber responses.

curl -sS -X POST "$BLUESTONE_API_BASE/notification-external/messages/webhook/list" \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "webhookId": "YOUR_WEBHOOK_ID",
    "status": "ERROR",
    "page": 0,
    "pageSize": 10
  }'

The generated External Notifications API does not expose a dedicated server-side replay endpoint for webhook messages. To replay, retrieve the message content from Get messages and reprocess it through your own queue or receiver tooling. Use the stored message ID to deduplicate the replay from the original attempt.

Signature verification

Every webhook message is signed with a SHA256 hash based on the request payload and the webhook secret. The signature is sent in the x-bs-signature request header.

Verify the x-bs-signature value before processing the payload. Store the secret securely, rotate it if it is exposed, and keep signature failures visible in your logs.

The verification model is HMAC-SHA256 over the raw request body using the webhook secret. Preserve the exact raw request body bytes; do not parse and reserialize JSON before verification.

Node/TypeScript Express receiver

import crypto from "node:crypto";
import express from "express";

const app = express();
const WEBHOOK_SECRET = process.env.BLUESTONE_WEBHOOK_SECRET ?? "";
const processedEventKeys = new Set<string>();

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

function normalizeSignature(signature: string): string {
  return signature.startsWith("sha256=") ? signature.slice("sha256=".length) : signature;
}

function verifySignature(rawBody: Buffer, signatureHeader: string, secret: string): boolean {
  if (!signatureHeader || !secret) {
    return false;
  }

  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const actual = normalizeSignature(signatureHeader);
  const expectedBuffer = Buffer.from(expected, "hex");
  const actualBuffer = Buffer.from(actual, "hex");

  return (
    expectedBuffer.length === actualBuffer.length &&
    crypto.timingSafeEqual(expectedBuffer, actualBuffer)
  );
}

app.post("/webhooks/bluestone", (req, res) => {
  const rawBody = req.body as Buffer;
  const signature = req.header("x-bs-signature") ?? "";

  if (!verifySignature(rawBody, signature, WEBHOOK_SECRET)) {
    res.sendStatus(401);
    return;
  }

  const payload = JSON.parse(rawBody.toString("utf8")) as {
    timestamp: number;
    events: Array<{ changes: { eventType: string; entityIds?: string[] } }>;
  };

  payload.events.forEach((event, index) => {
    const eventKey = crypto
      .createHash("sha256")
      .update(`${payload.timestamp}:${index}:${JSON.stringify(event.changes)}`)
      .digest("hex");

    if (processedEventKeys.has(eventKey)) {
      return;
    }

    processedEventKeys.add(eventKey);

    console.log("Received Bluestone event", {
      eventType: event.changes.eventType,
      entityIds: event.changes.entityIds ?? [],
      timestamp: payload.timestamp,
    });

    // Queue work here, then fetch current entity state from the Management API if needed.
  });

  res.sendStatus(204);
});

app.listen(3000, () => {
  console.log("Listening on http://localhost:3000/webhooks/bluestone");
});

Python FastAPI receiver

import hashlib
import hmac
import json
import os
from typing import Annotated

from fastapi import FastAPI, Header, HTTPException, Request, Response

app = FastAPI()
WEBHOOK_SECRET = os.environ["BLUESTONE_WEBHOOK_SECRET"].encode("utf-8")
processed_event_keys: set[str] = set()


def normalize_signature(signature: str) -> str:
    return signature.removeprefix("sha256=")


def verify_signature(raw_body: bytes, signature_header: str) -> bool:
    expected = hmac.new(WEBHOOK_SECRET, raw_body, hashlib.sha256).hexdigest()
    actual = normalize_signature(signature_header)
    return hmac.compare_digest(expected, actual)


@app.post("/webhooks/bluestone")
async def bluestone_webhook(
    request: Request,
    x_bs_signature: Annotated[str | None, Header(alias="x-bs-signature")] = None,
) -> Response:
    raw_body = await request.body()

    if not x_bs_signature or not verify_signature(raw_body, x_bs_signature):
        raise HTTPException(status_code=401, detail="Invalid signature")

    payload = json.loads(raw_body)
    timestamp = payload["timestamp"]

    for index, event in enumerate(payload.get("events", [])):
        changes = event["changes"]
        event_key = hashlib.sha256(
            f"{timestamp}:{index}:{json.dumps(changes, sort_keys=True)}".encode("utf-8")
        ).hexdigest()

        if event_key in processed_event_keys:
            continue

        processed_event_keys.add(event_key)

        print(
            "Received Bluestone event",
            {
                "eventType": changes["eventType"],
                "entityIds": changes.get("entityIds", []),
                "timestamp": timestamp,
            },
        )

        # Queue work here, then fetch current entity state from the Management API if needed.

    return Response(status_code=204)

Local testing

Use the test environment first. See Environments for API and auth base URLs.

For a quick visual test, create a temporary receiver with webhook.site and use it as the webhook URL. For local application testing, expose your local server with a tool such as ngrok, then create the webhook with the generated HTTPS URL.

After creating the webhook, use the ping webhook endpoint to verify the URL and secret before subscribing to production event types.

curl -sS -X POST "$BLUESTONE_API_BASE/notification-external/messages/webhook" \
  -G \
  --data-urlencode "url=$WEBHOOK_URL" \
  --data-urlencode "secret=$WEBHOOK_SECRET" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Receiver checklist

  1. Verify x-bs-signature with HMAC-SHA256 and the webhook secret.
  2. Store or queue the raw payload before doing slow downstream work.
  3. Deduplicate live deliveries with an idempotent key, and use the message ID from Get messages when replaying from the delivery log.
  4. Treat event ordering as best effort and fetch current entity state when order matters.
  5. Return 2xx only after the message is safely accepted.
  6. Return 429 or 503 with Retry-After when you want Bluestone PIM to retry after a specific delay.
  7. Monitor ERROR and WEBHOOK_INACTIVE messages with Get messages.