Skip to content

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 ​

  1. In XSelly, open แอปภายนอก (API) and then your app.
  2. Under Webhook, fill in your Webhook URL and press บันทึก Webhook URL.
  3. Switch on each event you want to receive.
  4. 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_typeFires when
stock_available_updatedA 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.0

Every request has the same envelope:

FieldTypeDescription
request_idstringUnique id of this delivery.
event_typestringThe event, e.g. "stock_available_updated".
request_timenumberWhen the request was sent (epoch ms).
dataobjectThe event's payload.

stock_available_updated ​

data.items[] holds one or more changes, because changes are batched.

FieldTypeDescription
idstringThe product variant id: the same id the API calls product_variant_id.
skustringVariant SKU. May be "" when the variant has no SKU.
oldnumberAvailable quantity before the change.
newnumberAvailable quantity after the change.
warehouse_idstringThe warehouse the change applies to.
update_timenumberWhen the change happened (epoch ms).
reasonstringWhy the quantity changed. See Reason codes.
order_idstringPresent only when an order caused the change. Never sent together with user_id.
user_idstringPresent 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:

  1. Read the body as raw bytes, before any JSON parsing. Re-serialising parsed JSON changes the bytes and breaks the signature.
  2. Compute the HMAC-SHA256 of those bytes with your Webhook Secret, as lowercase hex.
  3. 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 response
python
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 "", 200
go
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 2xx within one second to acknowledge. Do any heavy work after responding, not before.
  • Each event is delivered once. There are no retries. A non-2xx response or a timeout means that event is not sent again.
  • Deliveries can arrive out of order. Use update_time to put them in sequence. For example, ignore a change whose update_time is 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 ​

reasonCauseActor field
order_reservedA new order reserved stockorder_id
order_editedA product was edited in an orderorder_id
order_canceledThe order was cancelled by the buyer or resellerorder_id
order_canceled_by_systemThe order was cancelled by the systemorder_id
available_qty_reconciledAvailable quantity was reconciled to remaining stockorder_id
shipping_canceledA shipment was cancelledorder_id
variant_createdThe variant was createduser_id
user_adjustedA user edited the remaining quantityuser_id
user_addedA user added stock manuallyuser_id
returnedA return was receiveduser_id
purchasedA purchase was receiveduser_id
depositedDeposit or refilluser_id
user_deductedA user deducted stock manuallyuser_id
damagedDamaged stockuser_id
lostLost stockuser_id
withdrawnWithdrawnuser_id
user_setA user set the warehouse quantityuser_id
stock_countedStock countuser_id
system_initSystem initialisation—
admin_editedEdited by an administrator—
system_correctedSystem correction—
command_editedEdited by a system command—
fullfilment_updatedFulfilment service update—
assemble_added / assemble_deductedProduct assembly—
disassemble_added / disassemble_deductedProduct disassembly—
bundle_converted / bundle_editedBundle operations—
unknownOther 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.

XSelly Open Platform API v1 · Webhooks v2