> ## Documentation Index
> Fetch the complete documentation index at: https://docs.settlx.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Integration

> How to receive, verify, and handle Settlx webhook events

# 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](https://app.settlx.io). Once saved, copy it and store it as an environment variable in your server.

<Warning>
  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.
</Warning>

***

## 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.

<Warning>
  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.
</Warning>

<Warning>
  Always use a timing-safe comparison function. A standard `===` or `==` string check is vulnerable to timing attacks that can leak your secret.
</Warning>

***

## Verification examples

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  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 });
  });
  ```

  ```python Python (Flask) theme={null}
  import hmac
  import hashlib
  import os
  import time
  from flask import Flask, request, abort

  TOLERANCE_SECONDS = 5 * 60

  def verify_settlx_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
      if not signature_header:
          return False

      # Parse "t=1735689600,v1=abc123..."
      timestamp = None
      v1_sigs = []
      for part in signature_header.split(','):
          k, _, v = part.partition('=')
          if k == 't':
              try:
                  timestamp = int(v)
              except ValueError:
                  return False
          elif k == 'v1':
              v1_sigs.append(v)
      if timestamp is None or not v1_sigs:
          return False

      # Reject if outside tolerance window (replay protection)
      if abs(int(time.time()) - timestamp) > TOLERANCE_SECONDS:
          return False

      # Recompute signature: HMAC over `<timestamp>.<rawBody>`
      expected = hmac.new(
          secret.encode(),
          f'{timestamp}.'.encode() + raw_body,
          hashlib.sha256,
      ).hexdigest()

      # Constant-time compare against any v1 candidate
      return any(hmac.compare_digest(expected, c) for c in v1_sigs)


  app = Flask(__name__)

  @app.route('/webhooks/settlx', methods=['POST'])
  def webhook():
      if not verify_settlx_webhook(
          request.data,                                       # raw bytes
          request.headers.get('X-Webhook-Signature', ''),
          os.environ['SETTLX_WEBHOOK_SECRET'],
      ):
          abort(401)

      event = request.get_json(force=True)

      if event['event'] == 'invoice.settled':
          order_id = event['data']['invoice']['metadata']['orderId']
          fulfill_order(order_id)

      return {'received': True}, 200
  ```

  ```php PHP theme={null}
  <?php

  const TOLERANCE_SECONDS = 300; // 5 minutes

  function verifySettlxWebhook(string $rawBody, string $signatureHeader, string $secret): bool {
      if ($signatureHeader === '') return false;

      // Parse "t=1735689600,v1=abc123..."
      $timestamp = null;
      $v1Sigs = [];
      foreach (explode(',', $signatureHeader) as $part) {
          [$k, $v] = array_pad(explode('=', $part, 2), 2, '');
          if ($k === 't') $timestamp = (int)$v;
          elseif ($k === 'v1') $v1Sigs[] = $v;
      }
      if ($timestamp === null || empty($v1Sigs)) return false;

      // Reject if outside tolerance window (replay protection)
      if (abs(time() - $timestamp) > TOLERANCE_SECONDS) return false;

      // Recompute signature: HMAC over `<timestamp>.<rawBody>`
      $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);

      // Constant-time compare against any v1 candidate
      foreach ($v1Sigs as $candidate) {
          if (hash_equals($expected, $candidate)) return true;
      }
      return false;
  }

  $payload = file_get_contents('php://input');
  $signature = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

  if (!verifySettlxWebhook($payload, $signature, getenv('SETTLX_WEBHOOK_SECRET'))) {
      http_response_code(401);
      exit('Invalid signature');
  }

  $event = json_decode($payload, true);

  if ($event['event'] === 'invoice.settled') {
      $orderId = $event['data']['invoice']['metadata']['orderId'];
      fulfill_order($orderId);
  }

  http_response_code(200);
  echo json_encode(['received' => true]);
  ```

  ```go Go theme={null}
  package main

  import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "encoding/json"
      "fmt"
      "io"
      "net/http"
      "os"
      "strconv"
      "strings"
      "time"
  )

  const ToleranceSeconds = 5 * 60

  func verifySettlxWebhook(rawBody []byte, signatureHeader, secret string) bool {
      if signatureHeader == "" {
          return false
      }

      var timestamp int64
      var v1Sigs []string
      for _, part := range strings.Split(signatureHeader, ",") {
          kv := strings.SplitN(part, "=", 2)
          if len(kv) != 2 {
              continue
          }
          switch kv[0] {
          case "t":
              ts, err := strconv.ParseInt(kv[1], 10, 64)
              if err == nil {
                  timestamp = ts
              }
          case "v1":
              v1Sigs = append(v1Sigs, kv[1])
          }
      }
      if timestamp == 0 || len(v1Sigs) == 0 {
          return false
      }

      // Reject if outside tolerance window (replay protection)
      diff := time.Now().Unix() - timestamp
      if diff < 0 {
          diff = -diff
      }
      if diff > ToleranceSeconds {
          return false
      }

      // Recompute signature: HMAC over `<timestamp>.<rawBody>`
      mac := hmac.New(sha256.New, []byte(secret))
      fmt.Fprintf(mac, "%d.", timestamp)
      mac.Write(rawBody)
      expected := hex.EncodeToString(mac.Sum(nil))
      expectedBytes := []byte(expected)

      for _, candidate := range v1Sigs {
          if hmac.Equal([]byte(candidate), expectedBytes) {
              return true
          }
      }
      return false
  }

  func webhookHandler(w http.ResponseWriter, r *http.Request) {
      body, err := io.ReadAll(r.Body)
      if err != nil {
          http.Error(w, "Bad request", http.StatusBadRequest)
          return
      }

      if !verifySettlxWebhook(
          body,
          r.Header.Get("X-Webhook-Signature"),
          os.Getenv("SETTLX_WEBHOOK_SECRET"),
      ) {
          http.Error(w, "Invalid signature", http.StatusUnauthorized)
          return
      }

      var event map[string]interface{}
      json.Unmarshal(body, &event)

      // Process event...

      w.Header().Set("Content-Type", "application/json")
      w.WriteHeader(http.StatusOK)
      fmt.Fprintf(w, `{"received":true}`)
  }
  ```
</CodeGroup>

***

## 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.

```javascript theme={null}
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

| Requirement                 | Detail                                                                                                                |
| --------------------------- | --------------------------------------------------------------------------------------------------------------------- |
| Use HTTPS                   | Your endpoint must be served over HTTPS. HTTP endpoints are rejected.                                                 |
| Verify before parsing       | Compute the HMAC on the raw body **before** calling `JSON.parse`.                                                     |
| Enforce timestamp tolerance | Reject signatures whose `t=` is more than 5 minutes from your clock. Without this, captured webhooks can be replayed. |
| Timing-safe comparison      | Use `crypto.timingSafeEqual` / `hmac.compare_digest` / `hmac.Equal` — never `===`.                                    |
| Respond fast                | Return `2xx` within **30 seconds**. Do heavy work asynchronously.                                                     |
| Store the secret securely   | Use an environment variable or secrets manager. Never commit it to source control.                                    |
| Deduplicate by `eventId`    | Your 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](/webhooks/overview) for the full retry schedule.
