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

# Webhooks

> Receive real-time HTTP callbacks when events occur in your Closient account.

Webhooks deliver real-time notifications to your server when events occur in Closient. Each webhook endpoint receives signed HTTP POST requests with JSON payloads.

## Creating a Webhook Endpoint

Configure endpoints in **Settings > Integrations** in your [Dashboard](https://www.closient.com/dashboard/), or via the [Integrations API](/api-reference/integrations).

Each endpoint has:

* **URL** — your HTTPS endpoint (HTTPS required in production)
* **Event types** — which events to subscribe to (e.g., `["offer.updated", "product.recalled"]`). Prefix matching is supported: subscribing to `"offer"` receives all `offer.*` events.
* **Filters** — optional per-event-type filtering rules (AND logic between filter keys, OR within a key)
* **Signing secret** — auto-generated 64-byte hex secret for payload verification

## Payload Format

Every webhook delivery is a `POST` request with a JSON body:

```json theme={null}
{
  "id": "evt_a1b2c3d4e5f6",
  "type": "offer.updated",
  "created_at": "2026-04-01T12:00:00+00:00",
  "data": {
    "gtin": "00012345678905",
    "offer_id": "ack3p9tw6x7r"
  }
}
```

| Field        | Type   | Description                                                   |
| ------------ | ------ | ------------------------------------------------------------- |
| `id`         | string | Unique event identifier (`evt_` prefix). Use for idempotency. |
| `type`       | string | Dot-separated event type (e.g., `offer.updated`).             |
| `created_at` | string | ISO 8601 timestamp of when the event was created.             |
| `data`       | object | Event-specific payload. Structure varies by event type.       |

## Request Headers

Every delivery includes these headers:

| Header                   | Example                   | Description                              |
| ------------------------ | ------------------------- | ---------------------------------------- |
| `Content-Type`           | `application/json`        | Always JSON.                             |
| `X-Closient-Signature`   | `t=1711972800,v1=5a3c...` | HMAC-SHA256 signature for verification.  |
| `X-Closient-Event-Type`  | `offer.updated`           | The event type (same as `type` in body). |
| `X-Closient-Delivery-ID` | `d290f1ee-6c54-...`       | Unique delivery attempt ID.              |
| `User-Agent`             | `Closient-Webhooks/1.0`   | Identifies Closient as the sender.       |

## Verifying Signatures

The `X-Closient-Signature` header uses a Stripe-style format that includes a timestamp for replay protection:

```
t=1711972800,v1=5a3c9f...
```

To verify:

1. Extract the timestamp (`t`) and signature (`v1`) from the header.
2. Construct the signed payload: `{timestamp}.{raw_json_body}`.
3. Compute HMAC-SHA256 using your signing secret.
4. Compare your computed signature with `v1` using a constant-time comparison.
5. Check the timestamp is within your tolerance window (recommended: 5 minutes).

<Tabs>
  <Tab title="Python">
    ```python theme={null}
    import hashlib
    import hmac
    import time


    def verify_closient_webhook(
        payload: bytes,
        signature_header: str,
        secret: str,
        tolerance_seconds: int = 300,
    ) -> bool:
        """Verify a Closient webhook signature.

        Args:
            payload: Raw request body bytes.
            signature_header: Value of X-Closient-Signature header.
            secret: Your endpoint's signing secret.
            tolerance_seconds: Max age in seconds (default 5 min).
        """
        # Parse header: t=123,v1=abc...
        parts = {}
        for element in signature_header.split(","):
            key, _, value = element.partition("=")
            parts[key.strip()] = value.strip()

        timestamp = parts.get("t", "")
        if not timestamp:
            return False

        # Replay protection
        age = abs(int(time.time()) - int(timestamp))
        if age > tolerance_seconds:
            return False

        # Compute expected signature
        signed_payload = f"{timestamp}.{payload.decode('utf-8')}"
        expected = hmac.new(
            secret.encode("utf-8"),
            signed_payload.encode("utf-8"),
            hashlib.sha256,
        ).hexdigest()

        # Check current signature (v1), then rotated signature (v1old)
        return (
            hmac.compare_digest(expected, parts.get("v1", ""))
            or hmac.compare_digest(expected, parts.get("v1old", ""))
        )
    ```
  </Tab>

  <Tab title="Node.js">
    ```javascript theme={null}
    const crypto = require("crypto");

    function verifyClosientWebhook(payload, signatureHeader, secret, toleranceSeconds = 300) {
      // Parse header: t=123,v1=abc...
      const parts = {};
      for (const element of signatureHeader.split(",")) {
        const [key, ...rest] = element.split("=");
        parts[key.trim()] = rest.join("=").trim();
      }

      const timestamp = parts.t;
      if (!timestamp) return false;

      // Replay protection
      const age = Math.abs(Math.floor(Date.now() / 1000) - parseInt(timestamp, 10));
      if (age > toleranceSeconds) return false;

      // Compute expected signature
      const signedPayload = `${timestamp}.${payload}`;
      const expected = crypto
        .createHmac("sha256", secret)
        .update(signedPayload, "utf-8")
        .digest("hex");

      // Check current signature (v1), then rotated signature (v1old)
      const v1Match = crypto.timingSafeEqual(
        Buffer.from(expected, "hex"),
        Buffer.from(parts.v1 || "", "hex").subarray(0, 32)
      );
      if (v1Match) return true;

      if (parts.v1old) {
        return crypto.timingSafeEqual(
          Buffer.from(expected, "hex"),
          Buffer.from(parts.v1old, "hex").subarray(0, 32)
        );
      }

      return false;
    }
    ```
  </Tab>
</Tabs>

<Warning>
  Always verify the signature before processing a webhook payload. Reject requests with missing, expired, or invalid signatures.
</Warning>

### Secret Rotation

When you rotate a signing secret via the API (`POST /endpoints/{id}/rotate-secret`), the previous secret remains valid for a **24-hour grace period**. During this window, deliveries include both signatures:

```
t=1711972800,v1=<current_secret_sig>,v1old=<previous_secret_sig>
```

Your verification code should check `v1` first, then fall back to `v1old`. After the grace period, only `v1` is included.

## Delivery Lifecycle

Each delivery progresses through these statuses:

| Status        | Description                                             |
| ------------- | ------------------------------------------------------- |
| `pending`     | Queued for delivery.                                    |
| `delivered`   | Your endpoint returned a 2xx response.                  |
| `failed`      | Non-2xx response or connection error. Will be retried.  |
| `dead_letter` | All retry attempts exhausted. Can be replayed manually. |

## Retry Policy

Failed deliveries are retried with exponential backoff and jitter:

| Attempt | Approximate Delay |
| ------- | ----------------- |
| 1       | Immediate         |
| 2       | 30 seconds        |
| 3       | 2 minutes         |
| 4       | 15 minutes        |
| 5       | 1 hour            |
| 6       | 4 hours           |
| 7       | 12 hours          |
| 8       | 24 hours          |

After 8 attempts, the delivery moves to `dead_letter`. You can replay dead-lettered deliveries via the API:

```bash theme={null}
POST /integrations/api/v1/webhooks/deliveries/{delivery_id}/replay/
```

### Auto-Disable

If an endpoint has a 100% failure rate over the last 24 hours (with at least one delivery attempt), Closient automatically disables it and sends an email notification. Re-enable it from the Dashboard or API after resolving the issue.

## Testing

Send a test event to verify your endpoint is reachable and signature verification works:

```bash theme={null}
POST /integrations/api/v1/webhooks/endpoints/{endpoint_id}/test/
```

This delivers a `test.ping` event synchronously and returns the HTTP status code from your endpoint.

## Best Practices

* **Return 2xx quickly** — acknowledge receipt, then process asynchronously. Closient times out after 30 seconds.
* **Handle duplicates** — use the `id` field for idempotency. The same event may be delivered more than once during retries.
* **Verify signatures** — always validate `X-Closient-Signature` before processing. Reject unsigned or expired requests.
* **Use HTTPS** — required in production. HTTP is only allowed in development.
* **Monitor health** — check delivery status in the Dashboard or via the [Integrations API](/api-reference/integrations). Address failures before they accumulate into auto-disable.

See the [Integrations API reference](/api-reference/integrations) for full endpoint management, delivery inspection, and filtering options.
