Skip to content
Syncore

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:

  1. Compute the signature over the raw body (raw bytes), before JSON parsing.
  2. Compare with a constant-time comparison.
  3. Reject requests whose timestamp is older than a few minutes (e.g. 5), to block replays.
  4. Answer 2xx quickly 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 2xx answer 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.