- Beta Webhook ke sistem Anda sendiri
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":{…}}
| Header | Meaning |
|---|---|
X-XQ-Timestamp | Unix seconds when this attempt was signed. Each retry is signed fresh. |
X-XQ-Signature | v1= + hex HMAC-SHA256 of {timestamp}.{raw body} with your endpoint secret |
X-XQ-Event | The event type, same as type in the body |
X-XQ-Delivery | This 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
- Read the raw request body as bytes. Do not parse and re-serialise JSON first — the signature covers the exact bytes sent.
- Reject the request if
X-XQ-Timestampis more than 5 minutes from your clock (replay guard). - Compute
v1=+ hex HMAC-SHA256 of"{timestamp}.{raw body}"with your secret. - Compare with
X-XQ-Signaturein constant time. Reject on mismatch with401.
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
3xxis 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:
| Operation | Scope |
|---|---|
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).
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.