Skip to main content

Webhook Integration

Every webhook delivery from Settlx is signed using HMAC-SHA256 with a timestamped, Stripe-style signature scheme. You must verify this signature before processing any event — unsigned, tampered, or replayed payloads must be rejected.

Your webhook secret

Set your webhook secret in Settings → Webhooks on the dashboard. Once saved, copy it and store it as an environment variable in your server.
Your webhook secret is write-only. It is never returned in any API response. Store it securely in an environment variable — never hard-code it.

How signing works

When Settlx delivers a webhook, it computes the signature over a canonical string built from the current Unix timestamp and the raw request body:
signing_string = "<timestamp>.<rawRequestBody>"
signature = HMAC-SHA256(signing_string, webhookSecret)
The result is sent in the X-Webhook-Signature header in this format:
X-Webhook-Signature: t=1735689600,v1=a3f1c9b...
Where:
  • t=<unix_seconds> — the timestamp the signature was generated (UTC, integer seconds since epoch).
  • v1=<hex> — the lowercase hex HMAC-SHA256 digest. The v1 prefix is the scheme version, kept stable so the verification algorithm can evolve without breaking integrators.
To verify, parse the header, recompute the HMAC on your side using the raw request body (before any JSON parsing), and compare it against the v1 value using a timing-safe comparison. You must also reject signatures whose timestamp is outside a tolerance window (5 minutes recommended) to prevent replay attacks.
Always sign over the raw bytes of the request body. Re-serializing the JSON before computing the HMAC will fail because object key ordering and whitespace are not canonical.
Always use a timing-safe comparison function. A standard === or == string check is vulnerable to timing attacks that can leak your secret.

Verification examples

import crypto from 'crypto';
import express from 'express';

const TOLERANCE_SECONDS = 5 * 60; // 5 minutes

function verifySettlxWebhook(rawBody, signatureHeader, secret) {
  if (!signatureHeader) return false;

  // Parse "t=1735689600,v1=abc123..."
  let timestamp = null;
  const v1Sigs = [];
  for (const part of signatureHeader.split(',')) {
    const [k, v] = part.split('=', 2);
    if (k === 't') timestamp = parseInt(v, 10);
    if (k === 'v1') v1Sigs.push(v);
  }
  if (!timestamp || v1Sigs.length === 0) return false;

  // Reject if outside tolerance window (replay protection)
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;

  // Recompute the signature: HMAC over `<timestamp>.<rawBody>`
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');
  const expectedBuf = Buffer.from(expected, 'hex');

  // Constant-time compare against any v1 candidate
  return v1Sigs.some((candidate) => {
    const candBuf = Buffer.from(candidate, 'hex');
    return candBuf.length === expectedBuf.length && crypto.timingSafeEqual(candBuf, expectedBuf);
  });
}

const app = express();

// IMPORTANT: use express.raw() — not express.json() — so req.body is a Buffer
app.post('/webhooks/settlx', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifySettlxWebhook(
    req.body,                                  // raw Buffer/string — DO NOT parse first
    req.headers['x-webhook-signature'],
    process.env.SETTLX_WEBHOOK_SECRET,
  );
  if (!ok) return res.status(401).send('Invalid signature');

  const event = JSON.parse(req.body);

  switch (event.event) {
    case 'invoice.settled':
      await fulfillOrder(event.data.invoice.metadata.orderId);
      break;
  }

  res.status(200).json({ received: true });
});

Replay attack prevention

Replay protection is built directly into the signature scheme. The t= value inside X-Webhook-Signature is the canonical signing timestamp — your verifier MUST reject any delivery whose timestamp is more than 5 minutes from your server’s current time. The examples above all enforce this with a TOLERANCE_SECONDS = 300 check before the HMAC comparison. Without that check, a single captured webhook could be replayed against your endpoint indefinitely. We also send an informational X-Webhook-Timestamp header containing the same moment in ISO 8601 format. It is for human readability only — always use the t= value from X-Webhook-Signature for verification, never the standalone header.

Idempotency

Settlx may deliver the same event more than once — for example if your server acknowledges after a retry was already in flight. Use the eventId field to deduplicate.
const { eventId, event, data } = JSON.parse(req.body);

const alreadyProcessed = await db.webhookEvents.findUnique({ where: { eventId } });
if (alreadyProcessed) {
  return res.status(200).json({ received: true });
}

await db.webhookEvents.create({ data: { eventId } });
// ... process event

Best practices

RequirementDetail
Use HTTPSYour endpoint must be served over HTTPS. HTTP endpoints are rejected.
Verify before parsingCompute the HMAC on the raw body before calling JSON.parse.
Enforce timestamp toleranceReject signatures whose t= is more than 5 minutes from your clock. Without this, captured webhooks can be replayed.
Timing-safe comparisonUse crypto.timingSafeEqual / hmac.compare_digest / hmac.Equal — never ===.
Respond fastReturn 2xx within 30 seconds. Do heavy work asynchronously.
Store the secret securelyUse an environment variable or secrets manager. Never commit it to source control.
Deduplicate by eventIdYour endpoint may receive duplicate deliveries — handle them idempotently.

Response requirements

Return any 2xx status within 30 seconds. 200, 201, and 204 are all accepted. Any non-2xx response or a timeout triggers a retry. See Webhook Overview for the full retry schedule.