Appearance
Receive webhooks
Webhooks are how XSelly tells you that something changed. XSelly sends an HTTP POST to a URL on your server, signed so you can prove it came from XSelly.
Webhooks v2
Signatures are now described in terms of your Webhook Secret, which was previously called your "API key". If you integrated before this change, your webhook secret has the same value as your former API key, so you do not need to change anything. Events, payloads and delivery are identical to v1.
Setting it up
- In XSelly, open แอปภายนอก (API) and then your app.
- Under Webhook, fill in your Webhook URL and press บันทึก Webhook URL.
- Switch on each event you want to receive.
- Copy the Webhook Secret shown there. You will use it to verify signatures.
Each app is configured separately, and each event has its own switch. One app can receive stock updates while another receives nothing. Switching an event off stops its delivery and keeps your URL for next time. Nothing is delivered while the URL is empty, whatever the switches say.
Events
event_type | Fires when |
|---|---|
stock_available_updated | A product variant's available quantity changes: orders reserving stock, manual adjustments, returns, cancelled shipments and more. |
The request
http
POST {your webhook URL}
Content-Type: application/json
X-XSelly-Signature: <hex HMAC-SHA256, see below>
User-Agent: xselly-webhook/1.0Every request has the same envelope:
| Field | Type | Description |
|---|---|---|
request_id | string | Unique id of this delivery. |
event_type | string | The event, e.g. "stock_available_updated". |
request_time | number | When the request was sent (epoch ms). |
data | object | The event's payload. |
stock_available_updated
data.items[] holds one or more changes, because changes are batched.
| Field | Type | Description |
|---|---|---|
id | string | The product variant id: the same id the API calls product_variant_id. |
sku | string | Variant SKU. May be "" when the variant has no SKU. |
old | number | Available quantity before the change. |
new | number | Available quantity after the change. |
warehouse_id | string | The warehouse the change applies to. |
update_time | number | When the change happened (epoch ms). |
reason | string | Why the quantity changed. See Reason codes. |
order_id | string | Present only when an order caused the change. Never sent together with user_id. |
user_id | string | Present only when a user made the change. Never sent together with order_id. |
json
{
"request_id": "evt_018f4b3c2a7e7d3ab1c9d2e4f5a6b7c8",
"event_type": "stock_available_updated",
"request_time": 1718385160415,
"data": {
"items": [
{
"id": "456313132",
"sku": "SHIRT-RED-M",
"old": 12,
"new": 11,
"warehouse_id": "12345",
"update_time": 1718385160123,
"reason": "order_reserved",
"order_id": "178465431"
},
{
"id": "456313134",
"sku": "SHIRT-BLUE-L",
"old": 14,
"new": 50,
"warehouse_id": "12345",
"update_time": 1718385160123,
"reason": "user_adjusted",
"user_id": "45431"
}
]
}
}Verifying the signature
Every request is signed with HMAC-SHA256 over the raw request body, using your app's Webhook Secret as the key. The lowercase hex digest is sent in the X-XSelly-Signature header.
To verify a request:
- Read the body as raw bytes, before any JSON parsing. Re-serialising parsed JSON changes the bytes and breaks the signature.
- Compute the HMAC-SHA256 of those bytes with your Webhook Secret, as lowercase hex.
- Compare it with the header using a constant-time comparison, and reject the request if they differ.
The Webhook Secret is used only for this. It is not an API credential, and it is separate from your OAuth Client Secret.
js
import crypto from 'node:crypto'
import express from 'express'
const app = express()
// express.raw keeps the body as a Buffer: the exact bytes that were signed.
app.post('/webhook/xselly', express.raw({ type: 'application/json' }), (req, res) => {
const expected = crypto
.createHmac('sha256', process.env.XSELLY_WEBHOOK_SECRET)
.update(req.body)
.digest('hex')
const received = req.get('X-XSelly-Signature') ?? ''
const valid =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))
if (!valid) return res.sendStatus(401)
res.sendStatus(200) // acknowledge first: you have one second
const event = JSON.parse(req.body.toString('utf8'))
setImmediate(() => handleEvent(event)) // your own processing, after the response
})php
<?php
$raw = file_get_contents('php://input'); // the exact bytes that were signed
$expected = hash_hmac('sha256', $raw, getenv('XSELLY_WEBHOOK_SECRET'));
$received = $_SERVER['HTTP_X_XSELLY_SIGNATURE'] ?? '';
if (!hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
// Acknowledge first: you have one second.
http_response_code(200);
if (function_exists('fastcgi_finish_request')) {
fastcgi_finish_request(); // PHP-FPM: send the response now, keep running
}
$event = json_decode($raw, true);
handleEvent($event); // your own processing, after the responsepython
import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["XSELLY_WEBHOOK_SECRET"].encode()
@app.post("/webhook/xselly")
def xselly_webhook():
raw = request.get_data() # the exact bytes that were signed
expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
received = request.headers.get("X-XSelly-Signature", "")
if not hmac.compare_digest(expected, received):
abort(401)
enqueue(request.get_json()) # hand off to a queue or worker; do not process here
return "", 200go
func xsellyWebhook(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body) // the exact bytes that were signed
if err != nil {
http.Error(w, "bad body", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(r.Header.Get("X-XSelly-Signature"))) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK) // acknowledge first: you have one second
go handleEvent(rawBody) // your own processing, after the response
}sh
# Check a captured body by hand
echo -n '<raw body>' | openssl dgst -sha256 -hmac '<your webhook secret>'Delivery rules
- Respond with any
2xxwithin one second to acknowledge. Do any heavy work after responding, not before. - Each event is delivered once. There are no retries. A non-
2xxresponse or a timeout means that event is not sent again. - Deliveries can arrive out of order. Use
update_timeto put them in sequence. For example, ignore a change whoseupdate_timeis older than the last one you applied to that variant and warehouse.
Staying in sync
Because a missed delivery is not sent again, keep webhooks as the fast path and add a slow one. Now and then, and after any downtime on your side, read available_qty from POST /v1/product/detail for the products you care about, and correct your copy.
Reason codes
reason | Cause | Actor field |
|---|---|---|
order_reserved | A new order reserved stock | order_id |
order_edited | A product was edited in an order | order_id |
order_canceled | The order was cancelled by the buyer or reseller | order_id |
order_canceled_by_system | The order was cancelled by the system | order_id |
available_qty_reconciled | Available quantity was reconciled to remaining stock | order_id |
shipping_canceled | A shipment was cancelled | order_id |
variant_created | The variant was created | user_id |
user_adjusted | A user edited the remaining quantity | user_id |
user_added | A user added stock manually | user_id |
returned | A return was received | user_id |
purchased | A purchase was received | user_id |
deposited | Deposit or refill | user_id |
user_deducted | A user deducted stock manually | user_id |
damaged | Damaged stock | user_id |
lost | Lost stock | user_id |
withdrawn | Withdrawn | user_id |
user_set | A user set the warehouse quantity | user_id |
stock_counted | Stock count | user_id |
system_init | System initialisation | — |
admin_edited | Edited by an administrator | — |
system_corrected | System correction | — |
command_edited | Edited by a system command | — |
fullfilment_updated | Fulfilment service update | — |
assemble_added / assemble_deducted | Product assembly | — |
disassemble_added / disassemble_deducted | Product disassembly | — |
bundle_converted / bundle_edited | Bundle operations | — |
unknown | Other internal adjustment | — |
The actor field shows which of order_id and user_id comes with the reason when the actor is known. Either may be absent.
Spelling
fullfilment_updated is spelled exactly like that on the wire. Match it as written.
Versioning
Changes are additive only. New fields, new event types and new reason codes may appear at any time. Parse leniently: ignore fields you do not know, and accept reason values you have not seen before.
