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

# Webhooks

> Receive real-time verification status updates at your own endpoint using HMAC-signed webhook events.

## Overview

Webhooks let your application receive instant notifications when a verification status changes. No polling required. When you create a verification, include a `webhook_url` and AnyCheck will `POST` a signed payload to that URL each time the status transitions.

<CardGroup cols={2}>
  <Card title="Real-time updates" icon="bolt">
    Status changes are delivered within seconds of the verification progressing.
  </Card>

  <Card title="HMAC-SHA256 signed" icon="shield-check">
    Every request includes a cryptographic signature so you can verify authenticity.
  </Card>

  <Card title="Per-verification" icon="circle-check">
    Each verification has its own webhook URL and optional secret. No global setup required.
  </Card>

  <Card title="Replay protection" icon="clock-rotate-left">
    Signatures include a timestamp. Reject requests older than 5 minutes to prevent replay attacks.
  </Card>
</CardGroup>

***

## Registering a Webhook

Pass `webhook_url` (and optionally `webhook_metadata.webhook_secret`) when creating a verification:

```bash theme={null}
curl -X POST https://api.anycheck.ai/verifications \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "service_id": "<service-uuid>",
    "folder_id": "<folder-uuid>",
    "configuration": { ... },
    "webhook_url": "https://your-app.com/webhooks/anycheck",
    "webhook_metadata": {
      "webhook_secret": "your-signing-secret"
    }
  }'
```

<Note>
  The `webhook_secret` is used to sign outgoing payloads. Choose a random, high-entropy string (at least 32 characters). Keep it secret and never expose it in client-side code.
</Note>

***

## Webhook Events

AnyCheck sends a `POST` request to your `webhook_url` when the verification status transitions to any of the following:

| Status                | Description                                                      |
| --------------------- | ---------------------------------------------------------------- |
| `IN_PROGRESS`         | The job has been picked up and is being processed                |
| `NEED_REVIEW`         | Processing complete, manual review required                      |
| `COMPLETED`           | Verification finished successfully                               |
| `PARTIALLY_COMPLETED` | Some verifications in the result group completed, others pending |
| `FAILED`              | Verification processing failed                                   |
| `PARTIALLY_FAILED`    | Some verifications in the result group failed                    |
| `FRAUD_DETECTED`      | Potential fraud detected; review required                        |
| `CANCELLED`           | Verification was cancelled                                       |

***

## Payload Structure

Every webhook delivers the following JSON body:

```json theme={null}
{
  "event": "verification.status_changed",
  "timestamp": "2024-06-15T10:30:45.123Z",
  "verification_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "COMPLETED",
  "data": {
    "face_match_confidence_level": "HIGH",
    "face_match_confidence_score": 0.962,
    "is_match_face": true
  }
}
```

| Field             | Type     | Description                                      |
| ----------------- | -------- | ------------------------------------------------ |
| `event`           | string   | Always `"verification.status_changed"`           |
| `timestamp`       | ISO 8601 | UTC timestamp of when the event was generated    |
| `verification_id` | UUID     | The verification this event belongs to           |
| `status`          | string   | The new verification status                      |
| `data`            | object   | The verification output data (service-dependent) |

***

## Security: Verifying the Signature

AnyCheck signs every webhook request with HMAC-SHA256. Two headers are included:

| Header                | Description                                                    |
| --------------------- | -------------------------------------------------------------- |
| `X-Webhook-Timestamp` | Unix timestamp (seconds) when the request was sent             |
| `X-Webhook-Signature` | Signature in the format `t={timestamp},sha256={hex_signature}` |

### Verification steps

**Step 1: Check the timestamp.** Reject requests where the timestamp is more than 5 minutes old (or in the future by more than 5 minutes) to prevent replay attacks.

**Step 2: Reconstruct the signed string.** Concatenate the timestamp and raw request body:

```
{timestamp}.{raw_body}
```

**Step 3: Compute HMAC-SHA256.** Use your `webhook_secret` as the key:

```
expected = HMAC-SHA256(key=webhook_secret, message="{timestamp}.{raw_body}")
```

**Step 4: Compare.** Extract the `sha256` portion from `X-Webhook-Signature` and compare using a constant-time equality check.

### Code examples

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

    def verify_webhook(secret: str, signature_header: str, timestamp_header: str, raw_body: bytes) -> bool:
        # Step 1: Check timestamp (reject if older than 5 minutes)
        ts = int(timestamp_header)
        age = abs(time.time() - ts)
        if age > 300:
            return False

        # Step 2: Reconstruct signed string
        signed_payload = f"{ts}.{raw_body.decode('utf-8')}".encode()

        # Step 3: Compute expected signature
        expected_sig = hmac.new(
            secret.encode(),
            signed_payload,
            hashlib.sha256
        ).hexdigest()
        expected = f"t={ts},sha256={expected_sig}"

        # Step 4: Constant-time comparison
        return hmac.compare_digest(signature_header, expected)

    # In your webhook handler (e.g., Flask):
    # raw_body = request.get_data()
    # is_valid = verify_webhook(
    #     secret=WEBHOOK_SECRET,
    #     signature_header=request.headers.get("X-Webhook-Signature"),
    #     timestamp_header=request.headers.get("X-Webhook-Timestamp"),
    #     raw_body=raw_body,
    # )
    ```
  </Tab>

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

    function verifyWebhook(secret, signatureHeader, timestampHeader, rawBody) {
      // Step 1: Check timestamp (reject if older than 5 minutes)
      const ts = parseInt(timestampHeader, 10);
      const age = Math.abs(Date.now() / 1000 - ts);
      if (age > 300) return false;

      // Step 2 + 3: Reconstruct and compute
      const signedPayload = `${ts}.${rawBody}`;
      const expectedSig = crypto
        .createHmac('sha256', secret)
        .update(signedPayload)
        .digest('hex');
      const expected = `t=${ts},sha256=${expectedSig}`;

      // Step 4: Constant-time comparison
      return crypto.timingSafeEqual(
        Buffer.from(signatureHeader),
        Buffer.from(expected)
      );
    }

    // In your webhook handler (e.g., Express):
    // app.post('/webhooks/anycheck', express.raw({ type: 'application/json' }), (req, res) => {
    //   const isValid = verifyWebhook(
    //     process.env.WEBHOOK_SECRET,
    //     req.headers['x-webhook-signature'],
    //     req.headers['x-webhook-timestamp'],
    //     req.body,
    //   );
    //   if (!isValid) return res.status(401).send('Invalid signature');
    //   // process payload...
    // });
    ```
  </Tab>

  <Tab title="Go">
    ```go theme={null}
    package main

    import (
      "crypto/hmac"
      "crypto/sha256"
      "encoding/hex"
      "fmt"
      "math"
      "strconv"
      "time"
    )

    func verifyWebhook(secret, signatureHeader, timestampHeader string, rawBody []byte) error {
      ts, err := strconv.ParseInt(timestampHeader, 10, 64)
      if err != nil {
        return fmt.Errorf("invalid timestamp header")
      }

      age := math.Abs(float64(time.Now().Unix() - ts))
      if age > 300 {
        return fmt.Errorf("webhook timestamp too old or too far in the future")
      }

      mac := hmac.New(sha256.New, []byte(secret))
      mac.Write([]byte(fmt.Sprintf("%d.%s", ts, rawBody)))
      expectedSig := hex.EncodeToString(mac.Sum(nil))
      expected := fmt.Sprintf("t=%d,sha256=%s", ts, expectedSig)

      if !hmac.Equal([]byte(signatureHeader), []byte(expected)) {
        return fmt.Errorf("signature mismatch")
      }
      return nil
    }
    ```
  </Tab>
</Tabs>

***

## Delivery Behavior

* **Timeout**: AnyCheck waits up to **10 seconds** for your endpoint to respond. If it times out or returns a non-2xx status, the delivery is considered failed.
* **No automatic retry**: Failed deliveries are not retried. Design your endpoint to be idempotent and use the verification ID as a deduplication key. If you miss an event, fetch the current state via `GET /verifications/{id}`.
* **Async delivery**: Webhooks are sent asynchronously in a background goroutine and do not block verification processing.

***

## Best Practices

<AccordionGroup>
  <Accordion title="Respond quickly and do work asynchronously">
    Your endpoint should return `200 OK` immediately and process the payload in a background queue. If your handler takes longer than 10 seconds, AnyCheck will consider the delivery failed.
  </Accordion>

  <Accordion title="Make your handler idempotent">
    The same event may be delivered more than once in edge cases. Use `verification_id` + `status` as a deduplication key to avoid processing the same transition twice.
  </Accordion>

  <Accordion title="Always verify the signature">
    Skip signature verification only during local development. In production, always reject requests with an invalid or missing signature.
  </Accordion>

  <Accordion title="Ignore unknown events">
    New event types may be added in the future. Write your handler to gracefully ignore any `event` value it does not recognize.
  </Accordion>

  <Accordion title="Use polling as a fallback">
    For critical use cases, pair webhooks with periodic polling (`GET /verifications/{id}`) to catch any missed events.
  </Accordion>
</AccordionGroup>
