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.
| Task | Method and path | Reference |
|---|---|---|
| Create webhook | POST /notification-external/webhooks | Create webhook |
| Search webhooks | POST /notification-external/webhooks/list | Search webhook |
| Subscribe to event types | PUT /notification-external/subscriptions/webhook/{webhookId}/events | Subscribe for given events for given webhook |
| List webhook event subscriptions | GET /notification-external/subscriptions/webhook/{webhookId}/events | Get all events subscriptions enabled for given webhook |
| Unsubscribe from event types | DELETE /notification-external/subscriptions/webhook/{webhookId}/events | Unsubscribe for given events for given webhook |
| Ping a webhook URL | POST /notification-external/messages/webhook?url={url}&secret={secret} | Ping webhook |
| Inspect webhook messages | POST /notification-external/messages/webhook/list | Get 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:
| Family | Event types |
|---|---|
| Product lifecycle and sync | PRODUCT_CREATED, PRODUCT_SYNC_DONE |
| Product assets, bundle, category, label, relation, state, variant | PRODUCT_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 metadata | PRODUCT_WATCH_METADATA_NAME, PRODUCT_WATCH_METADATA_NUMBER, PRODUCT_WATCH_METADATA_DESCRIPTION |
| Product attributes | PRODUCT_WATCH_ATTRIBUTE, PRODUCT_WATCH_ATTRIBUTE_UPDATE_VALUE, PRODUCT_WATCH_ATTRIBUTE_ASSOCIATION, PRODUCT_WATCH_ATTRIBUTE_DISASSOCIATION |
| Category lifecycle and structure | CATEGORY_CREATED, CATEGORY_REMOVED, CATEGORY_WATCH_ARCHIVE_STATE, CATEGORY_WATCH_MOVE, CATEGORY_WATCH_ORDER |
| Category metadata and assets | CATEGORY_WATCH_METADATA_NAME, CATEGORY_WATCH_METADATA_NUMBER, CATEGORY_WATCH_METADATA_DESCRIPTION, CATEGORY_WATCH_ASSET |
| Category attributes | CATEGORY_WATCH_ATTRIBUTE, CATEGORY_LOCAL_WATCH_ATTRIBUTE_ASSOCIATION, CATEGORY_LOCAL_WATCH_ATTRIBUTE_DISASSOCIATION, CATEGORY_LOCAL_WATCH_ATTRIBUTE_UPDATE_VALUE |
| Asset lifecycle and metadata | ASSET_CREATED, ASSET_WATCH_METADATA_NAME, ASSET_WATCH_METADATA_NUMBER, ASSET_WATCH_METADATA_DESCRIPTION |
| Asset attributes | ASSET_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
| Field | Where | Description |
|---|---|---|
timestamp | Webhook payload | Unix timestamp in milliseconds for the webhook payload. |
events | Webhook payload | Array of event records delivered in the message. |
events[].changes.eventType | Webhook payload | The event type value, such as PRODUCT_CREATED or PRODUCT_WATCH_ATTRIBUTE_UPDATE_VALUE. |
events[].changes.entityIds | Webhook payload | IDs of the affected product, category, asset, or other entity. |
events[].changes.* | Webhook payload | Event-family-specific payload/body details, such as metadataChanges, attributeChange, syncDoneData, stateChange, or parentChange. |
context | Payload body when relevant | Legacy context label, usually a language-like value such as en or no. |
contextId | Attribute payloads when relevant | The context where the change was applied. For global attributes, this can be null. |
affectedContextIds | Attribute payloads when relevant | Contexts whose effective value may be impacted by the change. |
id | WebhookMessageResponse from Get messages | Message ID for a stored webhook message. Use this message ID when deduplicating messages from the delivery log. |
createdAt | WebhookMessageResponse from Get messages | Message creation timestamp. |
status | WebhookMessageResponse from Get messages | Message status: TO_BE_SENT, SENT, ERROR, WEBHOOK_INACTIVE, or IN_PROGRESS. |
webhookId | WebhookMessageResponse from Get messages | The webhook that received or attempted to receive the message. |
calls | WebhookMessageResponse from Get messages | Delivery attempts, including request payload and subscriber response details. |
| Organization/customer context | Webhook configuration | The 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 response | Delivery behavior |
|---|---|
429 or 503 | Bluestone 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 403 | Delivery is not retried. Use these statuses only for permanent authentication or authorization failure. |
| Other non-success statuses | Delivery 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
- Verify
x-bs-signaturewith HMAC-SHA256 and the webhook secret. - Store or queue the raw payload before doing slow downstream work.
- Deduplicate live deliveries with an idempotent key, and use the message ID from
Get messageswhen replaying from the delivery log. - Treat event ordering as best effort and fetch current entity state when order matters.
- Return
2xxonly after the message is safely accepted. - Return
429or503withRetry-Afterwhen you want Bluestone PIM to retry after a specific delay. - Monitor
ERRORandWEBHOOK_INACTIVEmessages withGet messages.
Updated 5 days ago
