Skip to main content
  • Beta Webhooks to your own systems

Receive and verify webhooks

XQUARE sends each event as an HTTPS POST to your endpoint, signed with your endpoint's secret. Verify every request before you trust it.

What a delivery looks like​

POST /xquare/webhooks HTTP/1.1
Content-Type: application/json
User-Agent: xquare-connections-outbound/1
X-XQ-Timestamp: 1760000000
X-XQ-Signature: v1=d015c139e4b71f75e4fe8f51e82333b30aad59c9c0497eb1f2138f276ecca033
X-XQ-Event: voucher.redeemed
X-XQ-Delivery: 7b0e3c1a-…

{"id":"evt_…","type":"voucher.redeemed","api_version":"2026-10-08","created_at":"2026-10-20T09:12:44.000Z","space_id":"…","data":{…}}
HeaderMeaning
X-XQ-TimestampUnix seconds when this attempt was signed. Each retry is signed fresh.
X-XQ-Signaturev1= + hex HMAC-SHA256 of {timestamp}.{raw body} with your endpoint secret
X-XQ-EventThe event type, same as type in the body
X-XQ-DeliveryThis delivery's id — the same on every retry of it

The body is the event envelope {id, type, api_version, created_at, space_id, data}, described in Webhook events. A test ping has X-XQ-Event: webhook.test.

Verify in four steps​

  1. Read the raw request body as bytes. Do not parse and re-serialise JSON first — the signature covers the exact bytes sent.
  2. Reject the request if X-XQ-Timestamp is more than 5 minutes from your clock (replay guard).
  3. Compute v1= + hex HMAC-SHA256 of "{timestamp}.{raw body}" with your secret.
  4. Compare with X-XQ-Signature in constant time. Reject on mismatch with 401.

Then answer 2xx within 10 seconds (XQUARE gives up on the attempt after 10 s, 5 s to connect) and do the real work in a queue.

Node.js​

const crypto = require('node:crypto')
const TOLERANCE_S = 300

function verifyXquareSignature({ secret, timestamp, signature, rawBody, now = Math.floor(Date.now() / 1000) }) {
if (!secret || !timestamp || !signature || rawBody == null) return false
const ts = Number(timestamp)
if (!Number.isInteger(ts) || Math.abs(now - ts) > TOLERANCE_S) return false
const expected = 'v1=' + crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`, 'utf8').digest('hex')
const a = Buffer.from(expected), b = Buffer.from(String(signature))
return a.length === b.length && crypto.timingSafeEqual(a, b)
}

// Express: keep the raw body
app.post('/xquare/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyXquareSignature({
secret: process.env.XQUARE_WEBHOOK_SECRET,
timestamp: req.get('X-XQ-Timestamp'),
signature: req.get('X-XQ-Signature'),
rawBody: req.body.toString('utf8'),
})
if (!ok) return res.sendStatus(401)
queue.add(JSON.parse(req.body)) // dedupe on the event id there
res.sendStatus(204)
})

PHP​

<?php
const XQ_TOLERANCE_S = 300;

function xq_verify_signature(string $secret, string $timestamp, string $signature, string $rawBody, ?int $now = null): bool {
if ($secret === '' || $timestamp === '' || $signature === '' || !ctype_digit($timestamp)) {
return false;
}
$ts = (int) $timestamp;
$now = $now ?? time();
if (abs($now - $ts) > XQ_TOLERANCE_S) {
return false;
}
$expected = 'v1=' . hash_hmac('sha256', $ts . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}

$raw = file_get_contents('php://input');
if (!xq_verify_signature(getenv('XQUARE_WEBHOOK_SECRET'),
$_SERVER['HTTP_X_XQ_TIMESTAMP'] ?? '', $_SERVER['HTTP_X_XQ_SIGNATURE'] ?? '', $raw)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
// enqueue $event, dedupe on $event['id']
http_response_code(204);

Python​

import hmac, hashlib, time

TOLERANCE_S = 300

def verify_xquare_signature(secret: str, timestamp: str, signature: str, raw_body: bytes, now: int | None = None) -> bool:
if not (secret and timestamp and signature) or raw_body is None:
return False
try:
ts = int(timestamp)
except ValueError:
return False
now = int(time.time()) if now is None else now
if abs(now - ts) > TOLERANCE_S:
return False
mac = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest("v1=" + mac, signature)

# Flask
@app.post("/xquare/webhooks")
def xquare_webhook():
if not verify_xquare_signature(os.environ["XQUARE_WEBHOOK_SECRET"],
request.headers.get("X-XQ-Timestamp", ""),
request.headers.get("X-XQ-Signature", ""),
request.get_data()):
return "", 401
enqueue(request.get_json()) # dedupe on the event id
return "", 204

All three were checked against a body signed by XQUARE's sender: a good delivery verifies; a changed body, a wrong secret and a timestamp older than 5 minutes are refused.

Set up an endpoint​

In the vendor panel, open Connections, pick a connection and add an endpoint: its URL and the events it should receive. The endpoint's signing secret is shown once, when you add it. Store it in your receiver's secret store.

Your endpoint must:

  • be HTTPS on port 443. Other ports, plain HTTP and URLs with a user name or password are refused before anything is sent;
  • resolve only to public addresses;
  • not redirect. A 3xx is not followed and ends the delivery.

XQUARE only delivers to hosts it has approved for your Space. Until your endpoint's host is approved, deliveries are recorded as host_not_approved and nothing is sent. Contact XQUARE support to approve a host.

Test it​

The Connections page can send a test event to an endpoint: a signed webhook.test ping, so you can check your verification before real events flow. It goes through the same signing and retry rules as a real event. You can send at most 5 pings per endpoint per 10 minutes.

When deliveries keep failing​

After repeated failed deliveries the endpoint is marked failing, and later disabled. The Space owner gets a notice in the vendor panel each time, and again when a failing endpoint recovers. The Connections page lists each delivery with its outcome, and you can send a failed delivery again from there. A resend keeps the same X-XQ-Delivery id.

Manage endpoints from code​

Since 2026-10-28 you can also manage endpoints with your API key, at /v1/webhook-endpoints:

OperationScope
GET /v1/webhook-endpoints, GET /v1/webhook-endpoints/{id}webhook_endpoints:read
POST /v1/webhook-endpoints (the secret is in this answer only)webhook_endpoints:manage
PATCH /v1/webhook-endpoints/{id} (URL, events, enabled)webhook_endpoints:manage
DELETE /v1/webhook-endpoints/{id}webhook_endpoints:manage
POST /v1/webhook-endpoints/{id}/test (a webhook.test ping)webhook_endpoints:manage

The same rules apply as on the Connections page: HTTPS on port 443, subscribable events only, and host approval. Creating an endpoint does not approve its host; the answer's host_approved says whether it is approved. An endpoint belongs to a connection: if your Space has more than one connection, name it with connection_id; with none, create one on the Connections page first (409 connection_required).

v1 — expanding

These operations may still be labelled "planned" in the API reference until the next spec release. Secret rotation for webhook endpoints is not documented yet.

Next: Retries and idempotency · Webhook events