Webhook Setup Guide
Configure your store to push product and page data in real-time
On This Page
Overview
Emporiqa uses webhooks to receive product and page data from your e-commerce store. Instead of polling your store for updates, your store pushes data to us whenever changes occur.
Testing the webhook integration: Start free with $25 of signup credit. Sync your real catalog (up to 100,000 products) and validate webhook delivery in production mode. The credit covers about 100 conversations of testing.
Platform Support: We have ready-to-use integrations for WooCommerce, Drupal Commerce, Sylius, Magento 2, PrestaShop, and Shopware. For other platforms, our webhook API works with any system that can send HTTP requests.
How it works: Your store sends HTTP POST requests to your unique webhook URL. We process the data asynchronously and return a 202 Accepted response immediately.
Benefits of webhooks:
- Real-time updates - changes reflect immediately
- Efficient - only changed data is sent
- Store-controlled - you decide when to sync
- Batch support - send multiple events in one request
Getting Your Webhook URL
Your unique webhook URL is available in your store dashboard. It follows this format:
POST https://emporiqa.com/webhooks/sync/{store_id}/
To find your webhook URL:
- Log in to your Emporiqa dashboard
- Go to your store's Settings page
- Navigate to the Integration tab
- Copy the Webhook URL shown there
Authentication
All webhook requests must include an HMAC-SHA256 signature for authentication. A connection secret is automatically generated when you create a store.
Setting Up Signature Verification
- Copy the connection secret from your store settings
- Store this secret securely in your application
- Include the signature in the
X-Webhook-Signatureheader
Generating the Signature
The signature is an HMAC-SHA256 hash of the raw request body using your connection secret:
import hmac
import hashlib
signature = hmac.new(
webhook_secret.encode(),
request_body,
hashlib.sha256
).hexdigest()
headers = {
"X-Webhook-Signature": signature,
"Content-Type": "application/json"
}
Note: All webhook requests must include a valid HMAC-SHA256 signature. Requests without a signature or with an invalid signature will be rejected with 401 Unauthorized.
Event Types
Emporiqa supports the following event types:
| Event | Description | Data Type |
|---|---|---|
product.created |
New product added | ProductEventData |
product.updated |
Product modified | ProductEventData |
product.deleted |
Product removed | DeleteEventData |
product.availability |
Stock / availability changed only (lightweight) | ProductAvailabilityEventData |
page.created |
New page added | PageEventData |
page.updated |
Page modified | PageEventData |
page.deleted |
Page removed | DeleteEventData |
sync.start |
Start full sync session | SyncStartEventData |
sync.complete |
Complete sync, remove missing items | SyncCompleteEventData |
order.completed |
Purchase completed (conversion tracking) | OrderCompletedEventData |
Lightweight stock updates: product.availability
Stock and availability change far more often than product content. Sending a full
product.updated on every stock tick forces your store to rebuild the entire
product (all translations, descriptions, images, variations) for a change of two fields.
Send product.availability instead when only stock quantity or
availability changed, it carries just those fields and updates Emporiqa in place
without re-indexing. Keep sending the full product.updated whenever any other
product content changes.
{
"type": "product.availability",
"data": {
"identification_number": "product-123", // or "variation-456"
"sku": "ABC-123",
"availability_statuses": { "": "out_of_stock" }, // per channel: available | out_of_stock | backorder | preorder
"stock_quantities": { "": 0 } // per channel: integer or null (untracked)
}
}
The identification_number must match a product you have already synced. If
Emporiqa has not seen it yet, the event is skipped (no partial product is created).
A product.created/product.updated or a full sync establishes it first.
Channel keys ("" means store-wide) mirror the full product event.
Payload Format
Single Event
{
"type": "product.created",
"data": {
"identification_number": "PROD-123",
"sku": "WJ-001-M",
"channels": ["default"],
"names": {"default": {"en": "Winter Jacket"}},
...
}
}
Batch Events
Send multiple events in a single request for better performance:
{
"events": [
{
"type": "product.created",
"data": { ... }
},
{
"type": "product.updated",
"data": { ... }
},
{
"type": "page.created",
"data": { ... }
}
]
}
Tip: For initial data sync, wrap the import in a sync session: send sync.start, then all products as product.created events, then sync.complete. The widget only goes live once sync.complete is received, so a batch of product.created events sent without a sync session will not activate it. See Full Sync below.
Product Events
ProductEventData Fields
| Field | Type | Required | Description |
|---|---|---|---|
identification_number |
string | Yes | Unique product identifier in your store |
sku |
string | Yes | Stock Keeping Unit |
channels |
array of strings | No | Sales channel identifiers. Your integration determines the channel key (e.g. slugified shop/channel name). Use ["b2b", "retail"] for multi-channel products. |
names |
{channel: {lang: value}} |
Recommended | Product name/title per channel and language. Example: {"default": {"en": "Winter Jacket", "de": "Winterjacke"}} |
descriptions |
{channel: {lang: value}} |
Recommended | Product description per channel and language (plain text or HTML) |
links |
{channel: {lang: value}} |
Recommended | Full URL to the product page per channel and language |
attributes |
{channel: {lang: {k: v}}} |
No | Translatable key-value pairs per channel and language. Example: {"default": {"en": {"Color": "Red"}, "de": {"Farbe": "Rot"}}} |
categories |
{channel: {lang: [values]}} |
Recommended | Translatable category strings per channel and language. Supports hierarchy: {"default": {"en": ["Electronics > Laptops", "Sale"], "de": ["Elektronik > Laptops", "Angebote"]}}. Parent categories are auto-generated. |
brands |
{channel: value} |
Recommended | Brand/manufacturer name per channel. Example: {"default": "OutdoorPro"} |
prices |
{channel: [PriceObject]} |
Recommended | Price objects per channel. See Price Object below. |
availability_statuses |
{channel: value} |
Recommended | Product availability per channel. One of: available, preorder, backorder, out_of_stock, discontinued |
stock_quantities |
{channel: int|null} |
No | Stock count per channel. null means stock is not tracked. Enables low-stock messaging in chat. |
min_order_quantities |
{channel: int} |
No | Minimum order quantity per channel. Defaults to 1 when no minimum is configured. Present on both parent products and per-variation entries; for variable products, the parent's value is the maximum across its variations (strictest constraint). Lets the assistant block under-minimum cart actions and offer the merchant's minimum instead of failing silently. |
max_order_quantities |
{channel: int|null} |
No | Maximum order quantity per channel. null (or absent) means no limit. Present on parent and per-variation entries; for variable products the parent's value is the strictest (lowest) cap across variations. Lets the assistant cap quantities and tell the customer the maximum instead of letting the cart reject an over-limit add. (e.g. a "sold individually" product sends 1.) |
available_for_order |
boolean | No | Whether the product can be ordered. Defaults to true. Set false for catalog-mode / display-only / "request a quote" products. The assistant may describe them but never adds them to the cart. |
condition |
string|null | No | Item condition: new, used, or refurbished. null when unknown. Lets the assistant answer "is this new?" and handle condition-based objections. |
is_virtual |
boolean | No | Digital / downloadable / no-shipping product. Defaults to false. Lets the assistant answer "do you ship this?" correctly. |
images |
{channel: [urls]} |
No | Image URLs per channel. Example: {"default": ["https://example.com/img.jpg"]} |
parent_sku |
string|null | No | Parent product SKU (for variants) |
is_parent |
boolean | No | Is this a parent/grouped product |
variation_attributes |
{channel: {lang: [names]}} |
No | Translatable attribute names that define variants. Must match keys in attributes. Example: {"default": {"en": ["Color", "Size"], "de": ["Farbe", "Größe"]}} |
sync_session_id |
string|null | No | Session ID for full sync reconciliation |
Show part of a description, keep the rest for search.
Add <!--more-->
(or Drupal's <!--break-->)
anywhere in a descriptions value. Everything before the marker is shown to shoppers; everything after it is still indexed for search and used to answer questions, but is not displayed on the product card. Handy for FAQ, care, or sizing detail. It needs an editor that keeps HTML comments (some rich-text editors strip them on save).
Don't track inventory?
Turn on Treat all products as in stock under Platform > Store Settings. Incoming availability is then normalized on sync: it is forced to available, quantities are dropped, and every product is orderable. It applies to products synced from then on; run a full catalog re-import to apply it to products already synced, since an incremental sync only touches changed products.
Example Product Event
{
"type": "product.created",
"data": {
"identification_number": "PROD-123",
"sku": "WJ-001-M-BLK",
"channels": ["default"],
"names": {
"default": {"en": "Winter Jacket - Waterproof"}
},
"descriptions": {
"default": {"en": "Stay warm with our premium waterproof winter jacket..."}
},
"links": {
"default": {"en": "https://example.com/products/winter-jacket"}
},
"categories": {
"default": {"en": ["Clothing > Jackets > Winter"]}
},
"brands": {"default": "OutdoorPro"},
"prices": {
"default": [
{
"currency": "EUR",
"current_price": 149.99,
"regular_price": 199.99,
"price_incl_tax": 149.99,
"price_excl_tax": 126.04
}
]
},
"availability_statuses": {"default": "available"},
"stock_quantities": {"default": 25},
"min_order_quantities": {"default": 1},
"max_order_quantities": {"default": null},
"available_for_order": true,
"condition": "new",
"is_virtual": false,
"attributes": {
"default": {"en": {"Color": "Black", "Size": "Medium", "Material": "Polyester"}}
},
"images": {
"default": ["https://example.com/images/jacket-front.jpg", "https://example.com/images/jacket-back.jpg"]
}
}
}
Consolidated format: Each product event contains all translations and channels in one payload. The channel key is determined by your integration (e.g. slugified shop name, sales channel code). For multilingual stores, add more language keys: {"default": {"en": "...", "de": "...", "fr": "..."}}
Price Object Fields
Each entry in the prices array represents a price in a specific currency:
| Field | Type | Required | Description |
|---|---|---|---|
currency |
string | Yes | ISO 4217 currency code (e.g., EUR, USD, GBP) |
current_price |
float | Yes | Active selling price |
regular_price |
float | No | Original/list price (before discount). The salesperson shows a discount when this is higher than current_price. |
price_incl_tax |
float | No | Gross price including VAT/tax |
price_excl_tax |
float | No | Net price excluding VAT/tax |
tier_prices |
array | No | Volume discount tiers. Each entry: {"min_quantity": 5, "price": 35.99} |
Multi-Currency Example
Add one price entry per currency your store sells in:
"prices": {
"default": [
{"currency": "EUR", "current_price": 149.99, "regular_price": 199.99},
{"currency": "GBP", "current_price": 129.99},
{"currency": "USD", "current_price": 159.99}
]
}
Tip: When your widget uses ?currency=EUR, the salesperson selects the matching price entry. If no match is found, it falls back to the first entry.
Page Events
Pages are used for policy documents, FAQ pages, shipping information, and other non-product content that customers might ask about.
PageEventData Fields
| Field | Type | Required | Description |
|---|---|---|---|
identification_number |
string | Yes | Unique page identifier |
channels |
array of strings | No | Sales channel identifiers. Your integration determines the channel key (e.g. slugified shop/channel name). |
titles |
{channel: {lang: value}} |
Recommended | Page title per channel and language |
contents |
{channel: {lang: value}} |
Recommended | Full page content per channel and language (HTML supported) |
links |
{channel: {lang: value}} |
Recommended | Full URL to the page per channel and language |
sync_session_id |
string|null | No | Session ID for full sync reconciliation |
Example Page Event
{
"type": "page.created",
"data": {
"identification_number": "PAGE-SHIPPING",
"channels": ["default"],
"titles": {
"default": {"en": "Shipping Policy"}
},
"contents": {
"default": {"en": "<h2>Shipping Information</h2><p>We offer free shipping on orders over $50...</p>"}
},
"links": {
"default": {"en": "https://example.com/pages/shipping-policy"}
}
}
}
Delete Events
Only identification_number is required. The entire entity is deleted across all languages and channels:
{
"type": "product.deleted",
"data": {
"identification_number": "PROD-123"
}
}
Full Sync (Reconciliation)
Use sync sessions to perform a full data reconciliation. This is useful for:
- Initial data sync
- Periodic full sync to ensure data consistency
- Recovering from missed webhook events
- Removing products/pages that no longer exist in your store
How it works: Start a sync session, send all your products/pages, then complete the session. Any items not sent during the session will be automatically marked as deleted.
Sync Session Flow
Start Session
Send sync.start event with a unique session ID
Send Data
Send product.created/updated events in batches with the sync_session_id
Complete Session
Send sync.complete event - items not seen are marked deleted
Step 1: Start Sync Session
{
"type": "sync.start",
"data": {
"session_id": "sync-2024-01-15-abc123",
"entity": "products"
}
}
Step 2: Send Products/Pages with Session ID
Include the sync_session_id in each event to register it with the sync session:
{
"events": [
{
"type": "product.updated",
"data": {
"identification_number": "PROD-001",
"sku": "SKU-001",
"names": {"default": {"en": "Product One"}},
"sync_session_id": "sync-2024-01-15-abc123",
...
}
},
{
"type": "product.updated",
"data": {
"identification_number": "PROD-002",
"sku": "SKU-002",
"names": {"default": {"en": "Product Two"}},
"sync_session_id": "sync-2024-01-15-abc123",
...
}
}
]
}
Tip: Send products in batches of 50-100 for optimal performance. You can send as many batches as needed before completing the session.
Step 3: Complete Sync Session
After sending all products/pages, complete the session. Any items not seen during the session will be marked as deleted:
{
"type": "sync.complete",
"data": {
"session_id": "sync-2024-01-15-abc123",
"entity": "products"
}
}
Important: Sync sessions expire after 24 hours. Make sure to complete your sync within this time window.
Overlapping syncs: Only one sync session per entity (products or pages) can be active at a time. Starting a new sync while one is already in progress returns 409 Conflict. Wait for the current sync to complete before starting a new one. If a previous sync failed and sync.complete was never sent, the stale session is automatically replaced after 1 hour, so you can retry without issues.
SyncStartEventData / SyncCompleteEventData Fields
| Field | Type | Required | Description |
|---|---|---|---|
session_id |
string | Yes | Unique identifier for this sync session |
entity |
string | Yes | Either 'products' or 'pages' |
Response Codes
Success Response
On success, the webhook returns HTTP 202 Accepted:
{
"status": "accepted",
"queued": 5,
"errors": []
}
Error Responses
| Status | Scenario | Response |
|---|---|---|
| 404 | Store ID not found | {"error": "Store not found"} |
| 403 | Subscription not active | {"error": "Subscription not active"} |
| 401 | Missing/invalid connection secret | {"error": "Invalid connection secret"} |
| 400 | Invalid JSON or payload | {"error": "Invalid JSON"} |
| 409 | Sync session already active | {"error": "Sync session already active", "active_session_id": "..."} |
| 429 | Rate limit exceeded | {"error": "Rate limit exceeded"} with Retry-After header |
| 429 | Ingestion busy (backpressure) | {"error": "Sync queue is saturated, retry shortly", "reason": "..."} with Retry-After header |
| 500 | Server error | {"error": "Failed to queue events"} |
| 503 | Temporarily unable to accept events | {"error": "Cannot accept events right now, retry shortly"} with Retry-After header |
Rate Limit: Pay-as-you-go: 3,000 requests per 60 seconds. Enterprise: 10,000 per 60 seconds. Each request can contain multiple events in a batch, so use batching to stay within the limit during bulk imports. If exceeded, the response includes a Retry-After header with the number of seconds to wait.
Handling 429: Rate Limits and Backpressure
A 429 with a Retry-After header means one of two things. The response body tells you which:
| Situation | Body | What it means |
|---|---|---|
| Rate limiting | No reason field. X-RateLimit-* headers are present. |
You sent more requests per 60 seconds than your plan allows. |
| Ingestion backpressure | A reason field is present: queue_depth, store_spool_full, spool_full or disk_low. |
We are still working through data you already sent, and asked you to pause before sending more. |
The reason value is informational — it tells you which limit we reached, not what you should do differently. There is nothing to tune on your side for any of the four.
The correct behaviour is the same for both: sleep for Retry-After seconds, then continue your loop from where it stopped and keep sending at full speed. Re-send the batch that was refused, then carry on with the next one. A 503 carrying a Retry-After header is transient too, and wants exactly the same handling.
In particular, do not:
- Do not restart the sync from the first product. Nothing you already sent was lost.
- Do not abandon the sync or report it as failed. A 429 is not an error condition.
- Do not drop to one request per
Retry-Afterinterval. This is the expensive mistake: pausing and then sending a single batch per minute turns an import that would finish in a few hours into one that takes days. Pause once, then resume the loop.
Backpressure during a large import is normal, not an error. A big first-time catalogue import is expected to fill our processing queue and pace itself into waves: you send until we ask you to pause, we drain, you resume. The sync still completes — it just moves at the speed we can index rather than the speed you can send.
As a rough guide, indexing runs at roughly 45,000 products per hour for a single-language catalogue, so a 150,000-product import takes about three hours end to end, and longer for multi-language catalogues where each product is indexed once per language. A steady trickle of 429s throughout is the system working as intended.
No data is lost. A shed batch was never accepted, so nothing was half-processed. Re-sending exactly the same batch is correct and safe — events are idempotent, so a batch that arrives twice is not a problem either.
Your sync session cannot get stranded. A request containing only sync.start or only sync.complete is never refused for backpressure, so you can always open a session and always close it. Send those two as their own requests rather than bundling them with product events, and a busy period can never leave a session open.
Example: a loop that handles both cases
import time
def post_batch(events):
"""Sign and POST one batch, returning the raw HTTP response.
Same signing as the send_webhook() example above, but returns the
response object rather than the parsed body so we can read the
status code and the Retry-After header.
"""
payload = json.dumps({"events": events}).encode()
signature = hmac.new(
WEBHOOK_SECRET.encode(), payload, hashlib.sha256
).hexdigest()
return requests.post(
WEBHOOK_URL,
data=payload,
headers={
"Content-Type": "application/json",
"X-Webhook-Signature": signature,
},
)
def send_batch_with_backoff(events, max_attempts=10):
"""Send one batch. Returns when it has been accepted."""
for attempt in range(max_attempts):
response = post_batch(events)
if response.status_code not in (429, 503):
return response
# A rate limit, ingestion backpressure, or a transient 503.
# Same response to all three: wait the requested time, then send
# this same batch again. Do NOT skip it, and do NOT restart the
# whole sync.
wait = int(response.headers.get("Retry-After", 60))
time.sleep(wait)
raise RuntimeError("Batch still refused after %d attempts" % max_attempts)
# Send the whole catalogue. The loop keeps running at full speed; only
# the batch that was actually refused waits.
send_batch_with_backoff([sync_start_event]) # never shed
for batch in batches_of(all_products, size=100):
send_batch_with_backoff(batch)
send_batch_with_backoff([sync_complete_event]) # never shed
Code Examples
Python
import hmac
import hashlib
import json
import requests
WEBHOOK_URL = "https://emporiqa.com/webhooks/sync/YOUR_STORE_ID/"
WEBHOOK_SECRET = "your_webhook_secret"
def send_webhook(events):
payload = json.dumps({"events": events}).encode()
signature = hmac.new(
WEBHOOK_SECRET.encode(),
payload,
hashlib.sha256
).hexdigest()
headers = {
"Content-Type": "application/json",
"X-Webhook-Signature": signature,
}
response = requests.post(WEBHOOK_URL, data=payload, headers=headers)
return response.json()
# Example: Create a product
result = send_webhook([{
"type": "product.created",
"data": {
"identification_number": "PROD-123",
"sku": "SKU-001",
"channels": ["default"],
"names": {"default": {"en": "My Product"}},
"descriptions": {"default": {"en": "Product description here"}},
"links": {"default": {"en": "https://mystore.com/products/my-product"}},
"categories": {"default": {"en": ["Electronics"]}},
"brands": {"default": "MyBrand"},
"prices": {"default": [
{"currency": "EUR", "current_price": 79.99, "regular_price": 99.99}
]},
"availability_statuses": {"default": "available"}
}
}])
print(result)
JavaScript (Node.js)
const crypto = require('crypto');
const WEBHOOK_URL = 'https://emporiqa.com/webhooks/sync/YOUR_STORE_ID/';
const WEBHOOK_SECRET = 'your_webhook_secret';
async function sendWebhook(events) {
const payload = JSON.stringify({ events });
const signature = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(payload)
.digest('hex');
const headers = {
'Content-Type': 'application/json',
'X-Webhook-Signature': signature,
};
const response = await fetch(WEBHOOK_URL, {
method: 'POST',
headers,
body: payload
});
return response.json();
}
// Example usage
sendWebhook([{
type: 'product.created',
data: {
identification_number: 'PROD-123',
sku: 'SKU-001',
channels: [''],
names: {'': {en: 'My Product'}},
descriptions: {'': {en: 'Product description here'}},
links: {'': {en: 'https://mystore.com/products/my-product'}},
categories: {'': {en: ['Electronics']}},
brands: {'': 'MyBrand'},
prices: {'': [
{ currency: 'EUR', current_price: 79.99, regular_price: 99.99 }
]},
availability_statuses: {'': 'available'}
}
}]).then(console.log);
PHP
<?php
$webhookUrl = 'https://emporiqa.com/webhooks/sync/YOUR_STORE_ID/';
$webhookSecret = 'your_webhook_secret';
function sendWebhook($events) {
global $webhookUrl, $webhookSecret;
$payload = json_encode(['events' => $events]);
$signature = hash_hmac('sha256', $payload, $webhookSecret);
$headers = [
'Content-Type: application/json',
'X-Webhook-Signature: ' . $signature,
];
$ch = curl_init($webhookUrl);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$response = curl_exec($ch);
curl_close($ch);
return json_decode($response, true);
}
// Example usage
$result = sendWebhook([[
'type' => 'product.created',
'data' => [
'identification_number' => 'PROD-123',
'sku' => 'SKU-001',
'channels' => [''],
'names' => ['' => ['en' => 'My Product']],
'descriptions' => ['' => ['en' => 'Product description here']],
'links' => ['' => ['en' => 'https://mystore.com/products/my-product']],
'categories' => ['' => ['en' => ['Electronics']]],
'brands' => ['' => 'MyBrand'],
'prices' => ['' => [
['currency' => 'EUR', 'current_price' => 79.99, 'regular_price' => 99.99]
]],
'availability_statuses' => ['' => 'available']
]
]]);
print_r($result);
?>
Troubleshooting
401 Unauthorized - Invalid signature
Ensure you're using the correct connection secret and computing the signature on the raw JSON body (not form-encoded).
- Check that the secret matches exactly (including whitespace)
- Verify you're signing the raw body, not a re-serialized version
- Ensure the Content-Type header is application/json
400 Bad Request - Invalid payload
Check that your JSON is valid and all required fields are present.
- Validate your JSON structure
- Ensure all required fields are included
- Check that field types match (strings vs numbers)
429 Too Many Requests during a large import
Expected, and not a failure. A first-time import of a large catalogue is meant to pace itself into waves. Check the response body: a reason field means ingestion backpressure, no reason field means a plan rate limit.
- Sleep for
Retry-Afterseconds, then resume your loop at full speed - Re-send the refused batch — it was never accepted, so nothing was lost or half-applied
- Don't restart the sync from the beginning and don't mark it failed
- If the import is crawling, check you are not sending only one batch per
Retry-Afterinterval — see Handling 429
Products not appearing in search
Data processing is asynchronous. Products may take a few seconds to become searchable.
- Wait 30-60 seconds after sending data
- Check that the language codes in your product data are correct
- Verify the subscription limits haven't been exceeded
Order Completed Webhook (Conversion Tracking)
Send an order.completed event when a customer completes a purchase. Emporiqa uses this to attribute sales to chat sessions and display revenue data on your conversion tracking dashboard.
How It Works
- Customer interacts with the chat widget (the widget stores a session ID in the browser)
- Customer completes a purchase on your store
- Your store sends an
order.completedevent to the Emporiqa webhook endpoint - Emporiqa links the purchase to the chat session and records the conversion
- Revenue and conversion data appear on your dashboard
OrderCompletedEventData Fields
| Field | Type | Required | Description |
|---|---|---|---|
order_id |
string | Yes | Your store's order ID or number |
total |
decimal | Yes | Order total amount (e.g., 149.99) |
currency |
string | No | 3-letter ISO 4217 currency code (e.g., 'USD', 'EUR') |
emporiqa_session_id |
string | No | The Emporiqa chat session ID. If provided, the purchase is linked directly to that session. If omitted, the conversion is recorded without session attribution. |
items |
array | No | Line items in the order. Each item can include product_id (string), quantity (integer), and price (decimal). Some integrations also include variation_id. |
Example
{
"type": "order.completed",
"data": {
"order_id": "ORD-2026-4521",
"total": 149.99,
"currency": "EUR",
"emporiqa_session_id": "abc123-def456-ghi789",
"items": [
{"product_id": "product-42", "quantity": 2, "price": 49.99},
{"product_id": "product-87", "quantity": 1, "price": 50.01}
]
}
}
Getting the Session ID
The chat widget stores the session ID in the browser. Your store can retrieve it via the EmporiqaCartHandler or by reading the widget's session storage. Our platform plugins (WooCommerce, Drupal, Sylius) handle this automatically.
Deduplication: If you send the same order_id twice, the second event is ignored. This makes it safe to retry on failure.
Note: This event uses the same webhook URL and HMAC signature as product/page events. No separate configuration is needed.
Dry Run (Test Connection)
Test your webhook integration without processing any data. Add ?dry_run=true to the webhook URL. The server runs the full validation pipeline (signature, rate limit, schema) but does not queue or store anything.
Request
POST https://emporiqa.com/webhooks/sync/YOUR_STORE_ID/?dry_run=true
Content-Type: application/json
X-Webhook-Signature: <HMAC-SHA256 signature>
{
"type": "product.created",
"data": { ... your normal product payload ... }
}
Response
{
"status": "dry_run",
"signature": "valid",
"events_validated": 1,
"events": [
{
"type": "product.created",
"valid": true,
"identification_number": "product-123",
"sku": "JACKET-TRAIL",
"languages_detected": ["de", "en"],
"channels_detected": ["default"],
"fields": {
"names": true,
"descriptions": true,
"links": true,
"categories": true,
"attributes": true,
"prices": true,
"variation_attributes": true
},
"is_parent": true,
"parent_sku": null,
"warnings": []
}
]
}
What It Checks
- Signature: HMAC-SHA256 verification (same as production)
- Rate limit: Counts against rate limits (same as production)
- Schema validation: Pydantic schema for each event type
- Field presence: Which translatable fields are populated
- Warnings: Data quality hints (missing descriptions, variation_attributes not matching attribute keys, empty prices)
Safe to use in production: Dry run requests never create, update, or delete any data. The only side effect is rate limit consumption.
Order Tracking API (Optional)
Emporiqa supports optional order tracking. Unlike webhooks (where your store pushes data to us), order tracking works in the opposite direction: when a customer asks about their order status in the chat, Emporiqa sends a request to your store's order tracking API endpoint.
How It Works
- Configure your Order Tracking API URL in Store Settings > Integration
- Customer asks "Where is my order #12345?" in the chat widget
- Emporiqa sends a POST request to your endpoint with the order identifier
- Your endpoint returns the order status (JSON, HTML, or plain text)
- The salesperson formats the response and presents it to the customer
Request Format
Emporiqa sends a POST request to your configured URL:
POST https://yourstore.com/api/orders/track
Content-Type: application/json
X-Emporiqa-Signature: <HMAC-SHA256 signature>
{
"order_identifier": "12345",
"timestamp": 1707400000,
"user_id": "customer-456",
"verification_fields": {
"email": "[email protected]",
"phone_last_4": "1234"
}
}
Request fields:
order_identifier(string, always present): the order number or tracking ID provided by the customertimestamp(integer, always present): Unix timestamp of the requestuser_id(string, optional): the verified customer ID, extracted from the signed token passed when embedding the widget. This ID is cryptographically verified by Emporiqa before being forwarded, so you can trust it to restrict results to that customer's orders.verification_fields(object, optional): customer-provided verification data collected by the chat assistant before looking up the order. Only present if you have configured verification fields in Store Settings > Integration. The keys match the field names you defined, and the values are what the customer provided. Use these to verify the customer is authorized to view the order (e.g., check that the email matches the order record).
Authentication
Requests are signed with HMAC-SHA256 using the same connection secret configured in your store settings. The signature is included in the X-Emporiqa-Signature header. Verify this signature on your side to ensure the request is from Emporiqa.
Expected Response
Your endpoint should return one of the following:
- JSON (recommended): Return order data as JSON with
Content-Type: application/json - HTML: Return formatted HTML with
Content-Type: text/html(HTML tags will be stripped) - Plain text: Return plain text with order details
- 404: Return HTTP 404 if the order is not found
Note: If order tracking is not configured, customers asking about orders will be handled by the Customer Support agent instead. There is no need to sync order data via webhooks. The order tracking feature queries your store's API in real-time.
Need Help with Integration?
Stuck somewhere in the integration? Email us with what you're trying to do and what's not working, and we'll help.
Contact Support