Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.xquik.com/llms.txt

Use this file to discover all available pages before exploring further.

Every webhook delivery is signed with HMAC-SHA256 over a string built from a timestamp, nonce, and the raw body. Always verify the signature AND reject stale or replayed requests before processing events.

Headers sent with every delivery

Read these headers from each webhook POST before parsing the JSON body. The signature is the trust boundary; use Content-Type and User-Agent only for handler routing and logs.

Timestamp

X-Xquik-Timestamp is Unix epoch milliseconds. Reject requests outside the 5-minute tolerance window.

Nonce

X-Xquik-Nonce is 16 random bytes in hex. Store recent values for 5 minutes and reject repeats.

Signature

X-Xquik-Signature is sha256=<hex> over <timestamp>.<nonce>.<rawBody> keyed with the endpoint secret.

Raw JSON body

Content-Type is always application/json. Verify the raw body bytes before parsing or re-serializing JSON.

Sender logs

User-Agent is xquik-webhooks/1.0 (+https://xquik.com). Log it for diagnostics, but never trust it instead of the signature.

How it works

  1. Xquik computes the signing string <timestamp>.<nonce>.<rawBody>.
  2. Xquik computes sha256= + HMAC-SHA256(webhook secret, signing string).
  3. Your server recomputes the signature with the same secret over the raw body.
  4. Reject the request if the signature does not match in constant time.
  5. Reject the request if the timestamp is older than 5 minutes (clock-skew tolerant).
  6. Reject the request if the nonce was already seen in the last 5 minutes (replay protection).

Receiver hardening handoff

Use this handoff when you promote a webhook receiver from a signed test request to production monitor events. It keeps signing, replay protection, idempotency, and incident routing visible in one review record.
{
  "workflow": "webhook_receiver_hardening",
  "signature": {
    "raw_body": true,
    "signing_string": "<timestamp>.<nonce>.<rawBody>",
    "headers": [
      "X-Xquik-Signature",
      "X-Xquik-Timestamp",
      "X-Xquik-Nonce"
    ],
    "replay_window_ms": 300000,
    "secret_logging": "never"
  },
  "nonce_store": {
    "key": "webhook_id:nonce",
    "ttl_seconds": 300,
    "on_duplicate": "reject"
  },
  "production_idempotency": {
    "delivery_id": "502",
    "stream_event_id": "9002",
    "test_payload_omits_ids": true
  },
  "incident_row": {
    "source": "GET /api/v1/webhooks/15/deliveries",
    "fields": [
      "status",
      "attempts",
      "lastStatusCode",
      "lastError",
      "createdAt",
      "deliveredAt"
    ]
  },
  "handoff_state": "verify_raw_body_store_nonce_then_dedupe_delivery"
}

Raw body first

Keep raw request bytes available until signature verification succeeds. Parse JSON only after the HMAC check passes.

Nonce store

Store each nonce with the webhook ID for 5 minutes. Reject duplicates before queueing or acknowledging the event.

Secret hygiene

Store the webhook secret once, use it only for HMAC checks, and never write it to logs, queues, traces, or incident rows.

Incident fields

Send status, attempts, lastStatusCode, lastError, createdAt, and deliveredAt from the deliveries API to your alerting or support queue.

Implementation

# Generate a test signature to verify your implementation
TS=$(date +%s)000
NONCE=$(openssl rand -hex 16)
BODY='{"test":"payload"}'
SIG=$(printf "%s.%s.%s" "$TS" "$NONCE" "$BODY" | openssl dgst -sha256 -hmac "your_webhook_secret" | sed 's/.*= /sha256=/')
echo "X-Xquik-Timestamp: $TS"
echo "X-Xquik-Nonce: $NONCE"
echo "X-Xquik-Signature: $SIG"

Security checklist

Never process webhook payloads without verifying the signature first. An unverified payload could be a spoofed request.
Use timingSafeEqual (Node.js), hmac.compare_digest (Python), or hmac.Equal (Go). String equality (===) is vulnerable to timing attacks.
Compute the HMAC over the raw request body bytes, not a re-serialized JSON object. Re-serialization can alter whitespace or key ordering.
We recommend responding within 10 seconds. Process events asynchronously if your handler is slow.

Idempotency

Webhook deliveries can be retried on failure, so your endpoint may receive the same event multiple times. Production monitor deliveries include deliveryId and streamEventId. Use deliveryId as the webhook delivery idempotency key. Use streamEventId when your system should process one monitor event only once across webhook retries or endpoint changes. Do not hash the raw request body when deliveryId is available. webhook.test deliveries include eventType, data, and timestamp; they omit monitor idempotency fields. Use them to verify signatures and receiver reachability, then skip production event de-dupe for the test request.

Production store contract

Use a persistent store before acknowledging production events. Scope nonce, delivery, and event keys by webhook ID so retries for one endpoint never block another endpoint. Duplicate deliveryId or streamEventId checks should return 2xx after verification, because the receiver has already accepted the work.
{
  "record_type": "webhook_receiver_store_contract",
  "webhook_id": "15",
  "nonce_key": "webhook:15:nonce_hash",
  "nonce_ttl_seconds": 300,
  "delivery_key": "webhook:15:delivery:502",
  "event_key": "event:9002",
  "duplicate_delivery_status": 200,
  "duplicate_event_status": 200,
  "event_join": "GET /api/v1/events/9002",
  "ack_after": "signature_verified_and_queue_row_written",
  "ack_status": 202,
  "slow_work": "async_worker",
  "delivery_log": "GET /api/v1/webhooks/15/deliveries",
  "shared_storage_excludes": [
    "endpoint_signing_values",
    "raw_request_body",
    "raw_signature",
    "full_headers"
  ]
}
Keep raw request bytes only for signature verification. Store the nonce cache marker, delivery key, event key, join route, and processing status in the shared table or queue. Return 2xx only after verification and a durable queue write. Move slow enrichment, exports, CRM sync, or alerting to an async worker so the receiver can finish within the 10-second delivery timeout.
const processedDeliveries = new Set();
const processedEvents = new Set();

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const payload = req.body.toString();

  if (!verifyWebhook(req, WEBHOOK_SECRET)) {
    return res.status(401).send("Invalid signature");
  }

  const event = JSON.parse(payload);

  if (event.eventType === "webhook.test") {
    return res.status(200).send("Test accepted");
  }

  if (
    typeof event.deliveryId !== "string" ||
    typeof event.streamEventId !== "string"
  ) {
    return res.status(400).send("Missing idempotency fields");
  }

  if (processedDeliveries.has(event.deliveryId)) {
    return res.status(200).send("Delivery already processed");
  }

  processedDeliveries.add(event.deliveryId);

  if (processedEvents.has(event.streamEventId)) {
    return res.status(200).send("Event already processed");
  }

  processedEvents.add(event.streamEventId);
  handleEvent(event);

  res.status(200).send("OK");
});
The in-memory examples above work for single-process servers. In production, use a persistent store (database, Redis) to track processed delivery IDs and event IDs across restarts and multiple instances.
Last modified on May 23, 2026