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 a per-entity GET /{entity}/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.
The changelog is always on. Webhook deliveries must be included in your plan and switched on with the change.events.enabled setting; until then, registered webhooks receive nothing.
Changelog (Polling)
The changelog is an ordered, append-only feed of changes, read as one feed or with an endpoint per entity type. You consume it by polling with a cursor.
Changelog endpoints
| Endpoint | Entity |
|---|---|
GET /article/changelog | Articles |
GET /pricelist/changelog | Pricelists |
GET /price/changelog | Prices |
GET /currency/changelog | Currencies |
GET /vatclass/changelog | VAT classes |
GET /vatrate/changelog | VAT rates |
GET /taxarea/changelog | Tax areas |
GET /fx/changelog | FX rates |
GET /rounding/changelog | Rounding profiles |
GET /settings/changelog | Settings |
GET /article-taxclass/changelog | Article–tax-class mappings |
One feed for every change
GET /changelog is the same log as one feed: every change of every entity type, in the order it was made, each numbered by a sequence that only grows. Use it when one consumer follows everything, such as a data warehouse.
GET /changelog?after_seq=1598&limit=100
{
"entries": [
{
"seq": 1599,
"entity_type": "price",
"entity_key": "SKU-CHAIR-001",
"composite_key": "base-sek",
"change_type": "updated",
"timestamp_ms": 1791885021461,
"content_hash": "247880b977135ff7",
"changed_by": "a1b2c3d4e5f6a1b2c3d4e5f6"
}
],
"next_cursor": 1599,
"has_more": false
}
- Start without
after_seqto read from the oldest change held; then pass each page'snext_cursorasafter_seq. entity_typelimits the feed to one type:article,pricelist,price,vat_rate,vat_class,tax_area,fx_rate,currency,rounding_profile,settingsorarticle_tax_class.composite_keyis the second half of a two-part key: a price's pricelist, a VAT rate's class, an article VAT class's tax area, an exchange rate's currency.- A position the log no longer holds answers
410; see Cursor expiry.
The per-entity endpoints above read the same changes, one type at a time, with an opaque cursor, as described below.
Parameters (same for all per-entity changelog endpoints)
| Parameter | Type | Default | Description |
|---|---|---|---|
cursor | string | — | Opaque cursor from a previous response. Omit for first request. |
since | ISO-8601 | — | Timestamp to start from (alternative to cursor on first request) |
limit | integer | 100 | Max items per page (1–1000) |
Basic usage
First request (start from a specific time):
GET /article/changelog?since=2026-01-01T00:00:00Z&limit=100
Response:
{
"items": [
{
"change_type": "created",
"code": "SKU-001",
"updated_at": "2026-01-15T10:30:00Z",
"updated_by": "api-key-abc"
},
{
"change_type": "updated",
"code": "SKU-002",
"updated_at": "2026-01-15T10:31:00Z",
"updated_by": "api-key-abc"
}
],
"next_cursor": "eyJzIjogMTIzNDV9",
"has_more": true
}
Subsequent requests (pass the cursor from the previous response):
GET /article/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.
Event fields
| Field | Description |
|---|---|
change_type | created, updated, or deleted |
code | Business identifier — SKU for articles, pricelist code for pricelists, currency code for currencies, etc. |
updated_at | ISO-8601 timestamp of when the change occurred |
updated_by | Who made the change (API key, user, or system) |
vat_class | VAT rate changelog only: the VAT class whose rate changed (code is the tax area) |
currency | FX rate changelog only: the currency whose rate changed (code is the base currency, EUR) |
tax_area | Article tax class changelog only: the tax area of the assignment (code is the SKU) |
Price changelog format
The price changelog (GET /price/changelog) uses a richer format since a price is identified by both a SKU and a pricelist:
{
"items": [
{
"change_type": "updated",
"sku": "SKU-001",
"pricelist": "PL-RETAIL",
"updated_at": "2026-01-15T10:31:00Z",
"updated_by": "api-key-abc"
}
],
"next_cursor": "eyJzIjogMjM0NTZ9",
"has_more": false
}
| Field | Description |
|---|---|
change_type | created, updated, or deleted |
sku | SKU code |
pricelist | Pricelist code |
updated_at | ISO-8601 timestamp |
updated_by | Who made the change |
Deleting an article or a pricelist deletes its prices, and each of them appears here as deleted. Deleting a large pricelist therefore produces at least one entry per price on it.
Changelog retention
The changelog keeps price changes apart from every other change, and holds each for as long as it is useful:
| Changes | Held | Default |
|---|---|---|
| Prices | For a limited time, and up to a number of changes | 7 days, at most 10,000,000 |
| Everything else (articles, pricelists, tax, exchange rates, ...) | Up to a number of changes | 1,000,000 |
Your plan sets the limits. Past a limit, the oldest changes are evicted.
Prices change far more often than anything else, and their history is kept with the prices themselves, so price changes are held long enough for the systems that follow them to catch up after an outage: a reader that polls at least once a week never misses one. A large bulk load of prices can still reach the price count limit sooner, so plan to resynchronise after one. It never pushes out changes to articles or anything else, which at their usual rate stay for months or years.
Cursor expiry
If your cursor falls behind the oldest change held, changes you have not read were evicted, and you'll receive:
HTTP 410 Gone
{
"timestamp": "2026-10-11T08:00:00Z",
"status": 410,
"error": "Gone",
"message": "The cursor is older than the oldest change held (sequence 4011662): changes were missed. Resynchronise from the entities, then read on from there with since."
}
Only the feed you read counts: an expired cursor on /price/changelog says nothing about /article/changelog. On the raw GET /changelog without entity_type, a change of any kind evicted after your position expires it.
A cursor that is not from this tenant's changelog, or is from before a restore, also gets 410. A value that is not a cursor this feed returned gets 400 Bad Request; pass next_cursor back unchanged.
What to do when your cursor expires:
- Note the current time, then re-fetch the data you need (e.g. all articles, the prices on your pricelists)
- Read on with
sinceset to the time you noted, so nothing changed during the resync is missed - Consider polling more frequently to avoid expiry
Recommended polling pattern
loop:
response = GET /{entity}/changelog?cursor={last_cursor}&limit=1000
if response.status == 410:
started = now()
perform_full_sync()
response = GET /{entity}/changelog?since={started}&limit=1000
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 these entity types, comma-separated (e.g. price,pricelist). 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": "PL-RETAIL",
"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": "PL-RETAIL",
"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).
Webhook event fields
| Field | Description |
|---|---|
entity_type | The type of entity that changed (article, price, pricelist, currency, vat_rate, vat_class, tax_area, fx_rate, rounding_profile, settings, article_tax_class) |
change_type | created, updated, or deleted |
entity_code | The primary business identifier (SKU code, pricelist code, etc.) |
composite_key | Secondary identifier when needed: the pricelist for a price, the VAT class for a VAT rate, the tax area for an article tax class, the currency for an FX rate |
changed_at | ISO-8601 timestamp of when the change occurred |
changed_by | Who made the change (API key, user, or system) |
content_hash | Hash of the entity's content. Same hash = same content. Useful for deduplication. |
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"
}
A filter can list several types, comma-separated ("price,pricelist"). 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 — updating downstream systems, triggering 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 webhook content_hash field helps detect duplicates.
What's NOT in the changelog event
Changelog 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 | Endpoint to fetch current state |
|---|---|
| Article | GET /article/{sku} |
| Pricelist | GET /pricelist/{code} |
| Price | GET /price/{sku}/{pricelist} |
| Currency | GET /currency/{code} |
| VAT rate | GET /vatrate/{tax_area}/{vat_class} |
| VAT class | GET /vatclass/{code} |
| Tax area | GET /taxarea/{code} |
This keeps changelog events lightweight.