Change Events
Quantaprice provides two mechanisms for tracking changes to your data: a polling changelog and push webhooks. Both cover all entity types — articles, pricelists, prices, currencies, VAT rates, tax areas, rounding profiles, and settings.
Overview
| Mechanism | How it works | Best for |
|---|---|---|
| Changelog | Poll GET /changelog with a cursor | Reliable sync, guaranteed ordering, backfill |
| Webhooks | Register a URL, receive POST on change | Real-time notifications, event-driven workflows |
Both systems use the same underlying change feed. A change recorded in the changelog will also trigger any matching webhooks.
Changelog (Polling)
The changelog is an ordered, append-only feed of all changes. Each entry has a globally unique, monotonically increasing sequence number. You consume it by polling with a cursor.
Basic usage
First request (no cursor — starts from the beginning of available history):
GET /changelog?limit=100
Response:
{
"items": [
{
"entity_type": "article",
"change_type": "created",
"entity_code": "SKU-001",
"composite_key": null,
"changed_at": "2026-01-15T10:30:00Z",
"changed_by": "api-key-abc",
"content_hash": "a1b2c3d4"
},
{
"entity_type": "price",
"change_type": "updated",
"entity_code": "SKU-001",
"composite_key": "retail-sek",
"changed_at": "2026-01-15T10:31:00Z",
"changed_by": "api-key-abc",
"content_hash": "e5f6a7b8"
}
],
"next_cursor": "eyJzIjogMTIzNDV9",
"has_more": true
}
Subsequent requests (pass the cursor from the previous response):
GET /changelog?cursor=eyJzIjogMTIzNDV9&limit=100
Continue polling until has_more is false, then poll periodically (e.g. every few seconds) to pick up new changes.
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
cursor | string | — | Opaque cursor from a previous response. Omit for first request. |
entity_type | string | — | Filter by entity type (optional). See entity types below. |
limit | integer | 100 | Number of items per page (1–1000) |
Event fields
| Field | Description |
|---|---|
entity_type | The type of entity that changed |
change_type | created, updated, or deleted |
entity_code | The business identifier — SKU code for articles/prices, pricelist code for pricelists, etc. |
composite_key | Secondary identifier when needed — e.g. pricelist code for price changes |
changed_at | ISO 8601 timestamp of when the change occurred |
changed_by | Identifier of who made the change (API key, user, or system) |
content_hash | Hash of the entity's content. Same hash = same content. Useful for deduplication. |
Entity types
| Entity type | entity_code | composite_key | Description |
|---|---|---|---|
article | SKU code | — | Article created, updated, or deleted |
pricelist | Pricelist code | — | Pricelist created, updated, or deleted |
price | SKU code | Pricelist code | Price set, changed, or removed |
currency | Currency code | — | Currency created, updated, or deactivated |
vat_rate | Tax area code | VAT class code | VAT rate added or changed |
vat_class | VAT class code | — | VAT class created, updated, or deactivated |
tax_area | Tax area code | — | Tax area created, updated, or deactivated |
fx_rate | EUR | Target currency code | Exchange rate updated |
rounding_profile | Profile code | — | Rounding profile changed |
settings | Setting key | — | System setting changed |
Deduplication
Changes are deduplicated by content hash. If you update a price to the same value it already has, no changelog entry is created. This means every entry in the changelog represents a real change to the entity's content.
The content_hash field can also be used on your end — if you receive an event with a hash you've already processed, you can safely skip it.
Changelog retention
The changelog holds a fixed number of entries determined by your plan tier. When the changelog is full, the oldest entries are evicted to make room for new ones.
| Tier | Max entries | Typical window at 100K changes/day |
|---|---|---|
| Starter | 1,000,000 | ~10 days |
| Standard | 10,000,000 | ~100 days |
| Enterprise | 100,000,000 | ~2.7 years |
For most workloads (continuous price updates, article changes), the changelog window spans weeks to months. During large bulk imports, the window shortens temporarily as old entries are evicted, then stabilizes again.
Cursor expiry
If you don't poll frequently enough and your cursor falls behind the oldest available entry, you'll receive:
HTTP 410 Gone
{
"error": "cursor_expired",
"oldest_available_sequence": 500001,
"message": "Your cursor has expired. Perform a full data sync and resume from the latest cursor."
}
What to do when your cursor expires:
- Perform a full sync of the data you need (e.g. re-fetch all articles, prices for your pricelists)
- Start polling again without a cursor (or from the latest cursor) to pick up future changes
- Consider polling more frequently to avoid expiry
Recommended polling pattern
loop:
response = GET /changelog?cursor={last_cursor}&limit=1000
if response.status == 410:
perform_full_sync()
last_cursor = null
continue
for event in response.items:
process(event)
last_cursor = response.next_cursor
if not response.has_more:
sleep(5 seconds)
Webhooks (Push)
Webhooks deliver change events to your HTTP endpoint in near real-time. Events are batched (collected over a short window) and delivered as a single POST request, signed with HMAC-SHA256.
Register a webhook
POST /webhooks
{
"url": "https://your-app.com/webhook",
"secret": "your-signing-secret",
"entity_type_filter": null
}
| Field | Type | Required | Description |
|---|---|---|---|
url | string | Yes | HTTPS endpoint to receive events |
secret | string | No | Secret for HMAC-SHA256 signature verification |
entity_type_filter | string | No | Only receive events for this entity type. null = all types. |
Response (201 Created):
{
"id": 1,
"url": "https://your-app.com/webhook",
"entity_type_filter": null,
"active": true,
"created_at": "2026-01-15T10:30:00Z",
"updated_at": "2026-01-15T10:30:00Z"
}
Delivery format
When changes occur, your endpoint receives a POST request:
POST https://your-app.com/webhook
Content-Type: application/json
X-Signature-256: sha256=a1b2c3d4e5f6...
{
"webhook_id": 1,
"delivered_at": "2026-01-15T10:30:05Z",
"events": [
{
"entity_type": "price",
"change_type": "updated",
"entity_code": "SKU-001",
"composite_key": "retail-sek",
"changed_at": "2026-01-15T10:30:00Z",
"changed_by": "api-key-abc",
"content_hash": "e5f6a7b8"
},
{
"entity_type": "price",
"change_type": "updated",
"entity_code": "SKU-002",
"composite_key": "retail-sek",
"changed_at": "2026-01-15T10:30:01Z",
"changed_by": "api-key-abc",
"content_hash": "c9d0e1f2"
}
]
}
Events are batched — a single delivery may contain multiple events that occurred within a short window (~500ms).
Signature verification
If you provided a secret, every delivery includes an X-Signature-256 header. To verify:
- Compute HMAC-SHA256 of the raw request body using your secret
- Compare with the value after
sha256=in the header
import hmac, hashlib
expected = hmac.new(
secret.encode(),
request.body,
hashlib.sha256
).hexdigest()
signature = request.headers["X-Signature-256"].removeprefix("sha256=")
assert hmac.compare_digest(expected, signature)
Retry behavior
If your endpoint returns a non-2xx status or times out (10 second limit), the delivery is retried with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | 1 second |
| 2 | 2 seconds |
| 3 | 4 seconds |
| 4 | 8 seconds |
| 5 | 16 seconds |
| 6 | 32 seconds |
| 7 | 64 seconds |
| 8 | 128 seconds |
| 9 | 256 seconds |
| 10 | 300 seconds (max) |
After 10 failed attempts, the delivery is dropped. The webhook remains active for future events.
Filtering
Use entity_type_filter to receive only specific event types. For example, to receive only price changes:
{
"url": "https://your-app.com/price-webhook",
"secret": "secret",
"entity_type_filter": "price"
}
Register multiple webhooks with different filters to route events to different endpoints.
Managing webhooks
| Operation | Endpoint |
|---|---|
| List all | GET /webhooks |
| Get one | GET /webhooks/{id} |
| Update | PUT /webhooks/{id} |
| Deactivate | DELETE /webhooks/{id} |
| Test | POST /webhooks/{id}/test |
The test endpoint sends a synthetic event to your URL so you can verify your endpoint is working and signature verification is correct.
Best Practices
Choosing between changelog and webhooks
- Use the changelog for reliable data synchronization. It's ordered, paginated, and you control the pace. If your consumer goes down, you resume from your last cursor.
- Use webhooks for real-time reactions — invalidating caches, triggering downstream processes, alerting. Webhooks are faster (near real-time) but less reliable than polling.
- Use both for the best of both worlds: webhooks for low-latency notification that something changed, changelog for reliable catch-up if you miss anything.
Handling bulk imports
During large data imports (e.g. annual price updates), a high volume of events will flow through the system. Your consumers should be prepared for:
- Higher event volume in both changelog and webhook deliveries
- Possible cursor expiry if you don't poll frequently enough during the import window
- Batched webhook deliveries with many events per request
If you know a bulk import is coming, consider increasing your polling frequency temporarily.
Idempotent processing
Design your event handlers to be idempotent. You may occasionally receive the same logical change more than once (e.g. after a cursor expiry and resync, or if a webhook is retried). The content_hash field helps detect duplicates.
What's NOT in the event
Change events are notifications, not data carriers. They tell you what changed, not what the new value is. To get the current state, fetch the entity using the appropriate API endpoint:
| Entity type | Endpoint to fetch current state |
|---|---|
article | GET /article/{sku} |
pricelist | GET /pricelist/{code} |
price | GET /price?sku={sku}&pricelist={code} |
currency | GET /currency/{code} |
vat_rate | GET /vat/rate?tax_area={area}&vat_class={class} |
This keeps events lightweight and avoids transmitting sensitive pricing data through webhook URLs.