Webhooks
Webhooks send a signed POST to your URL when something happens in your account, so you do not have to keep polling the API.
Events
| Event | When | data |
|---|---|---|
order.created |
New order from any channel | The order, as in GET /orders/{id} (with items) |
order.updated |
Order changed (status, shipment, …) | The order, as in GET /orders/{id} |
product.updated |
Products enabled / disabled or repriced | product_ids, change (price, enabled, disabled) |
stock.changed |
Your own stock or reserved quantity changed | product_id, sku, quantity, quantity_before, reserved |
stock.low |
Stock dropped to the low-stock threshold (once, not on every change below it) | same as stock.changed + threshold |
feed.generated |
A new feed file was built for a channel | channel, trigger, products_count, file_size, feed_url, generated_at |
The low-stock threshold is set in the account settings; with a threshold of 0, stock.low is not sent. The test button on the Webhooks page sends a ping event.
Delivery format
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Syncore-Webhooks/1
X-Syncore-Event: stock.low
X-Syncore-Delivery: 2f1c7a4e-7c55-4c1e-9d0b-1b9b2f0c3a10
X-Syncore-Timestamp: 1761724800
X-Syncore-Signature: sha256=5d41402abc4b2a76b9719d911017c592...
{
"id": "2f1c7a4e-7c55-4c1e-9d0b-1b9b2f0c3a10",
"event": "stock.low",
"created_at": "2026-10-29T10:00:00+02:00",
"data": {
"product_id": 1201,
"sku": "ABC-1001",
"quantity": 3,
"quantity_before": 6,
"reserved": 1,
"threshold": 5
}
}
id equals X-Syncore-Delivery and stays the same across retries — use it to ignore duplicate deliveries.
A feed.generated example:
{
"id": "8a0d3b8e-3f5e-4bde-a0f0-5b3c1f0d9e21",
"event": "feed.generated",
"created_at": "2026-10-29T10:00:00+02:00",
"data": {
"channel": "skroutz",
"trigger": "scheduled",
"products_count": 340,
"file_size": 182340,
"feed_url": "https://app.syncore.gr/feeds/your-shop/skroutz",
"generated_at": "2026-10-29T10:00:00+02:00"
}
}
Verifying the signature
The signature is an HMAC-SHA256 with the endpoint's secret over "{timestamp}.{raw body}":
X-Syncore-Signature = "sha256=" + hex( HMAC_SHA256( secret, X-Syncore-Timestamp + "." + raw_body ) )
Rules:
- Compute the signature over the raw body (raw bytes), before JSON parsing.
- Compare with a constant-time comparison.
- Reject requests whose timestamp is older than a few minutes (e.g. 5), to block replays.
- Answer
2xxquickly and do the heavy work asynchronously.
PHP
<?php
$secret = getenv('SYNCORE_WEBHOOK_SECRET');
$body = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_SYNCORE_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_SYNCORE_SIGNATURE'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $body, $secret);
if (! hash_equals($expected, $signature) || abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit;
}
$event = json_decode($body, true);
// ... handle $event['event'], $event['data']
http_response_code(204);
Node.js (Express)
import express from 'express';
import crypto from 'node:crypto';
const app = express();
app.post('/syncore/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const timestamp = req.get('X-Syncore-Timestamp') ?? '';
const signature = req.get('X-Syncore-Signature') ?? '';
const expected = 'sha256=' + crypto
.createHmac('sha256', process.env.SYNCORE_WEBHOOK_SECRET)
.update(`${timestamp}.${req.body}`)
.digest('hex');
const valid = signature.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!valid || Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body);
// ... handle event.event, event.data
res.sendStatus(204);
});
Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/syncore/webhook")
def syncore_webhook():
body = request.get_data() # raw bytes
timestamp = request.headers.get("X-Syncore-Timestamp", "")
signature = request.headers.get("X-Syncore-Signature", "")
secret = os.environ["SYNCORE_WEBHOOK_SECRET"].encode()
expected = "sha256=" + hmac.new(secret, timestamp.encode() + b"." + body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature) or abs(time.time() - int(timestamp or 0)) > 300:
abort(401)
event = request.get_json()
# ... handle event["event"], event["data"]
return "", 204
Retries
- Success is any
2xxanswer within 10 seconds. Redirects (3xx) are not followed and count as failures. - After a failure, new attempts follow at 1 minute, 5 minutes, 30 minutes and 2 hours (5 attempts in total). After that the delivery is marked failed.
- On the API & Webhooks page in Syncore you see the delivery history and can resend a failed delivery by hand (with the same
id). - Delivery order is not guaranteed: for orders, rely on the order's
updated_at.
Managing endpoints
Create endpoints on the API & Webhooks page in Syncore, or through the API with a token that has webhooks:manage. The URL must be a public https:// address (no localhost or private IPs). Up to 10 endpoints per business.
New endpoint
curl -s -X POST "https://app.syncore.gr/api/v1/webhooks" \
-H "Authorization: Bearer $SYNCORE_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/syncore/webhook", "events": ["order.created", "order.updated", "stock.low"]}'
{
"data": {
"id": 12,
"url": "https://example.com/syncore/webhook",
"events": ["order.created", "order.updated", "stock.low"],
"is_active": true,
"last_status": null,
"last_delivered_at": null,
"secret": "whsec_..."
}
}
The secret is shown only in this response. Store it right away.
List and delete
curl -s "https://app.syncore.gr/api/v1/webhooks" -H "Authorization: Bearer $SYNCORE_TOKEN"
curl -s -X DELETE "https://app.syncore.gr/api/v1/webhooks/12" -H "Authorization: Bearer $SYNCORE_TOKEN" # 204
Rotate the secret
curl -s -X POST "https://app.syncore.gr/api/v1/webhooks/12/rotate-secret" -H "Authorization: Bearer $SYNCORE_TOKEN"
Returns the endpoint with a new secret (once). The old secret stops working immediately, so first update your code to accept both for a short while, then rotate.