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

# Webhook Signature Verification

> Learn how to verify the signature of the webhook request to ensure it is genuine

When Suki calls your webhook, it proves the call is genuine by sending a **signature** you can recompute on your side. If the signature matches, the request really used your **secret key** and the **body was not altered** on the way to you.

<Warning>
  Verify every webhook before you parse the JSON body. Unverified POSTs can expose your integration to spoofed events and tampered payloads.
</Warning>

## What you need

Think of it in three plain pieces:

| Piece                     | What it is                                                                                                                                                                           |
| :------------------------ | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Secret key**            | A password-like string Suki puts on your **partner record** and shares with you during [partner onboarding](/documentation/partner-onboarding). Only your **server** should know it. |
| **`generated-at` header** | A number: **Unix time in milliseconds** when Suki built this request. You need it as part of the signed text.                                                                        |
| **`X-API-Key` header**    | The **expected signature** for this request, already turned into **hex** text. Your job is to compute the same hex and see if they are equal.                                        |

Suki sends each notification as a <Badge color="blue" size="sm">POST</Badge> with **`Content-Type: application/json`**. Your handler reads the raw body and the two headers above before you trust the payload.

## How to verify on your server

Perform the following steps on your server:

<Steps>
  <Step title="Read the raw body first" icon="file-lines">
    Read the request body **before** you parse JSON. Keep the exact bytes or string Suki sent (no pretty-printing, no trimming, no changing spaces).
  </Step>

  <Step title="Read the generated-at header" icon="clock">
    Read the **`generated-at`** header exactly as the string Suki sent (the millisecond timestamp).
  </Step>

  <Step title="Build the string to sign" icon="link">
    Join **`generated-at` + `:` + raw body** into one long string. Example shape: `1765977748432:{"status":"success",...}` (your timestamp and JSON will differ).
  </Step>

  <Step title="Run HMAC-SHA-256" icon="lock">
    Run **HMAC-SHA-256** using your **secret key** as the key and that long string as the data. **HMAC-SHA-256** is a standard "sign this text with this secret" operation; every language has a library for it.
  </Step>

  <Step title="Encode the digest as hex" icon="code">
    Turn the HMAC output into **hex** the same way Suki does (usually lowercase hex; if your comparison fails, ask your Suki contact whether casing matters).
  </Step>

  <Step title="Compare to X-API-Key" icon="check">
    Compare your hex to the **`X-API-Key`** header. Use a **constant-time** compare if your framework offers one, so attackers cannot guess the signature byte by byte.
  </Step>
</Steps>

If the values **do not match**, stop and return **4xx**. If they **match**, you can safely **parse the JSON** and run your business logic.

**Pseudocode**

```text theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
raw_body = read request body as received (string or bytes)
timestamp = request header "generated-at" exactly as sent
message = timestamp + ":" + raw_body

expected_signature_hex = request header "X-API-Key"
computed_signature_hex = hex( HMAC_SHA256(key = secret_key, data = message) )

if not constant_time_equal(computed_signature_hex, expected_signature_hex):
    return 401 or 400
# else: parse JSON and continue
```

<Tip>
  Read the **raw request body** before JSON parsing. Framework middleware that auto-parses JSON first will break verification because the signature covers the exact bytes Suki sent.
</Tip>

## Code examples

The examples below follow the pseudocode above line for line.

<Tabs>
  <Tab title="Python">
    ```python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
    import hashlib
    import hmac
    import json

    from flask import Flask, jsonify, request

    app = Flask(__name__)

    # Partner secret key from onboarding
    secret_key = "<partner_secret_key>"


    @app.route("/webhooks/notification", methods=["POST"])
    def handle_webhook():
        raw_body = request.get_data(as_text=True)
        timestamp = request.headers.get("generated-at", "")
        message = timestamp + ":" + raw_body

        expected_signature_hex = request.headers.get("X-API-Key", "")
        computed_signature_hex = hmac.new(
            secret_key.encode("utf-8"),
            message.encode("utf-8"),
            hashlib.sha256,
        ).hexdigest()

        if not hmac.compare_digest(computed_signature_hex, expected_signature_hex):
            return jsonify({"error": "Invalid signature"}), 401  # or 400

        # else: parse JSON and continue
        data = json.loads(raw_body)
        return jsonify({"message": "Notification received"}), 200
    ```
  </Tab>

  <Tab title="TypeScript">
    ```typescript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
    import crypto from "crypto";
    import express from "express";

    const app = express();

    // Partner secret key from onboarding
    const secret_key = "<partner_secret_key>";

    app.post("/webhooks/notification", express.raw({ type: "application/json" }), (req, res) => {
      const raw_body = req.body.toString("utf8");
      const timestamp = req.headers["generated-at"] as string;
      const message = timestamp + ":" + raw_body;

      const expected_signature_hex = req.headers["x-api-key"] as string;
      const computed_signature_hex = crypto
        .createHmac("sha256", secret_key)
        .update(message, "utf8")
        .digest("hex");

      const signatures_match =
        computed_signature_hex.length === expected_signature_hex.length &&
        crypto.timingSafeEqual(
          Buffer.from(computed_signature_hex, "utf8"),
          Buffer.from(expected_signature_hex, "utf8"),
        );

      if (!signatures_match) {
        return res.status(401).json({ error: "Invalid signature" });
      }

      // else: parse JSON and continue
      const data = JSON.parse(raw_body);
      return res.status(200).json({ message: "Notification received" });
    });
    ```
  </Tab>
</Tabs>

For a full handler that branches on **`status`** and processes **`_links`**, refer to the [Asynchronous notifications (webhook)](/api-reference/asynchronous/webhook) API reference.

## After verification

Once the signature matches:

1. Parse the JSON body.
2. Read the top-level **`status`** field and branch on **`"success"`** or **`"failure"`**.
3. Use **`session_id`**, **`encounter_id`**, and **`_links`** as described in [Payload & Response](/documentation/webhook/payload-and-response).

Return **2xx** (for example, 200) after you accept the notification so Suki can consider it delivered. You can run follow-up API calls or database work after that response if needed.

## Security best practices

<AccordionGroup>
  <Accordion icon="lock" title="Verify requests are from Suki">
    Implement **HMAC-SHA-256** verification using your **secret key**, the **`generated-at`** header, the raw body, and the **`X-API-Key`** header as described above.
  </Accordion>

  <Accordion icon="lock" title="Validate the payload">
    After the signature matches, check the JSON shape and **`status`**. Reject malformed or unverified requests with an appropriate **4xx** response.
  </Accordion>

  <Accordion icon="lock" title="Store the secret key securely">
    Keep the partner secret key in a secrets manager (for example AWS Secrets Manager or Azure Key Vault), not in source control.
  </Accordion>

  <Accordion icon="lock" title="Use HTTPS only">
    Your callback URL must use **HTTPS** with **TLS 1.2** or higher. Suki will not send webhooks to **HTTP** URLs. See [Configuration](/documentation/webhook/configuration).
  </Accordion>
</AccordionGroup>

For platform-wide guidance, refer to [Security & best practices](/api-reference/security-best-practices#webhook-security) and [Authentication FAQs](/documentation/faqs/authentication).

## Next steps

<Icon icon="file-lines" iconType="solid" /> Refer to [Payload & Response](/documentation/webhook/payload-and-response) for payload structure, example JSON bodies, implementation tips, and follow-up API response codes.

<Icon icon="file-lines" iconType="solid" /> Refer to [Event types](/documentation/webhook/event-types) for session completion, failure, timeout, and cancellation events.
