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

# Build Webhook Notification Receiver

> Use-case tutorial: Build a partner webhook receiver that verifies HMAC signatures and handles success and failure notifications

<Callout icon="info-circle" color="#FFE148">
  **What you will build**

  <p className="doc-tutorial-meta" aria-label="10 min, Beginner">
    <Badge size="sm" color="gray" icon="clock">10 min</Badge> |
    <Badge size="sm" color="gray" icon="user-graduate">Beginner</Badge>
  </p>

  * An HTTPS webhook endpoint for partner notifications
  * Signature checks on the raw request body before you parse JSON
  * Handling for success and failure notifications
  * Reject invalid signatures and acknowledge valid notifications
</Callout>

<Tip>
  **Using an AI coding tool?**

  Copy the following prompt to add the Suki developer documentation as a skill and [MCP server](/documentation/references/mcp) to your tool for better AI-assisted coding during the integration process. For all AI options (contextual menu, `llms.txt`, and `skill.md`), refer to [AI-optimized documentation](/documentation/references/ai-optimized-documentation).
</Tip>

<Prompt description="Install the Suki developer docs as a skill to get context on Suki's developer tools, APIs, and SDKs. Add the [MCP server](/documentation/references/mcp) for documentation search." icon="gear" iconType="regular" actions={["copy", "cursor"]}>
  Install the Suki developer docs as skill to get context on Suki's developer tools, APIs, and SDKs.

  npx skills add [https://developer.suki.ai](https://developer.suki.ai)

  Then add the Suki developer docs MCP server for access to documentation search. Follow the MCP instructions at [https://developer.suki.ai/documentation/references/mcp](https://developer.suki.ai/documentation/references/mcp).
</Prompt>

In this tutorial, you build a server-side webhook receiver for Suki partner notifications. You verify each request with **HMAC-SHA-256**, parse the JSON body only after verification succeeds, and branch on **`success`** or **`failure`**. Examples are provided in Python and TypeScript coding languages.

By the end of this tutorial, you'll have a working **`POST /webhooks/notification`** handler that rejects invalid signatures with **4xx** and acknowledges accepted notifications with **2xx**.

<span id="prerequisites" />

## Prerequisites

Before you begin, make sure you have:

* Completed [Partner onboarding](/documentation/get-started/partner-onboarding).
* Shared your HTTPS **callback URL** with Suki so it is stored on your partner record. You cannot register or change this URL through a self-service API. See [Configuration](/documentation/webhook/configuration).
* Your partner **secret key** from Suki (server-side only).
* A publicly reachable HTTPS endpoint (TLS 1.2 or higher).
* An integration that creates and ends ambient or Form filling sessions so Suki can send completion or failure events. Refer to [Build ambient session Streaming Client](/documentation/tutorials/ambient-websocket-code-example) or [Build Form filling Session Client](/documentation/tutorials/form-filling-websocket-code-example) tutorials for examples.

<Tip>
  Store your partner **secret key** only on your backend. Never expose it in a client-side or public application.
</Tip>

<span id="architecture" />

## Architecture

```mermaid actions={false} theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
%%{init: {'theme':'base', 'themeVariables': { 'primaryColor':'#FFE148','primaryTextColor':'#111827','primaryBorderColor':'#D4A017','lineColor':'#6F5410','secondaryColor':'#FFF394','tertiaryColor':'#FFFADE','tertiaryTextColor':'#111827','tertiaryBorderColor':'#D4A017','arrowheadColor':'#6F5410','fontSize':'14px','edgeLabelBackground':'#FFFADE'}}}%%
flowchart LR
  A[Suki POST callback] --> B[Read raw body and headers]
  B --> C[Verify HMAC-SHA-256]
  C --> D{Valid signature?}
  D -->|No| E[Return 4xx]
  D -->|Yes| F[Parse JSON and branch]
  F --> G[Return 2xx]
```

<span id="project-setup" />

## Project setup

1. Create a script or server project in the language used by this tutorial (Python or TypeScript).
2. Install the required dependencies:
   <Callout icon="gear" color="#929292">
     * **Python:** `pip install flask`

     * **TypeScript:** `npm install express`
   </Callout>
3. Configure your credentials:

   <Callout icon="gear" color="#929292">
     * Store your partner **secret key** on your backend only. Never expose it in browser code.
   </Callout>
4. Expose **`POST /webhooks/notification`** (or the path you registered with Suki) on a publicly reachable HTTPS URL.

<span id="how-signature-verification-works-in-this-tutorial" />

## How signature verification works in this tutorial

| Piece              | Role                                                                     |
| :----------------- | :----------------------------------------------------------------------- |
| Partner secret key | HMAC key from onboarding                                                 |
| `generated-at`     | Unix time in milliseconds, used in the signed string                     |
| Raw body           | Exact bytes or string Suki sent (no pretty-print, trim, or re-serialize) |
| Signed string      | `generated-at` + `:` + raw body                                          |
| `X-API-Key`        | Expected hex HMAC digest to compare against                              |

<Note>
  **Important points to remember:**

  * Verify against the **raw body** before JSON parse. Pretty-print, trim, or auto-parse middleware changes bytes and breaks HMAC.
  * Reject requests that are missing **`X-API-Key`** or have an empty digest with **4xx**.
  * Return **2xx** for accepted notifications, including timeout and cancellation events after you log identifiers.
  * Confirm the HTTPS callback URL is registered with Suki on your partner record.
</Note>

<span id="tutorial-steps" />

## Tutorial steps

Work through each step in order. Read the explanation, review the code example, then continue to the next step. The full code example is available in the accordion below.

<Steps>
  <Step title="Accept the POST Callback">
    Expose an HTTPS <Badge color="blue" size="sm">POST</Badge> route that matches the callback URL you shared with Suki. Keep the partner secret key on your backend only.

    <CodeGroup>
      ```python Python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      @app.route("/webhooks/notification", methods=["POST"])
      def handle_webhook():
      # Handle the partner notification here.
      ...
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      app.post(
      '/webhooks/notification',
      express.raw({ type: 'application/json' }),
      (req, res) => {
      // Handle the partner notification here.
      },
      );
      ```
    </CodeGroup>
  </Step>

  <Step title="Read the Raw Body and Headers">
    Read the **raw request body** before JSON parsing, plus the **`generated-at`** and **`X-API-Key`** headers. See [Verify signatures](/documentation/webhook/signature-verification).

    <CodeGroup>
      ```python Python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      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", "")
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      const rawBody = Buffer.isBuffer(req.body)
      ? req.body.toString('utf8')
      : String(req.body ?? '');
      const timestamp = String(req.headers['generated-at'] ?? '');
      const message = `${timestamp}:${rawBody}`;
      const expectedSignatureHex = String(req.headers['x-api-key'] ?? '');
      ```
    </CodeGroup>
  </Step>

  <Step title="Verify the HMAC Signature">
    Recompute **HMAC-SHA-256** over `generated-at` + `:` + raw body, encode as hex, and compare to **`X-API-Key`** with a constant-time compare.

    <CodeGroup>
      ```python Python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      computed_signature_hex = hmac.new(
      SECRET_KEY.encode("utf-8"),
      message.encode("utf-8"),
      hashlib.sha256,
      ).hexdigest()

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

      ```typescript TypeScript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      const computedSignatureHex = crypto
      .createHmac('sha256', SECRET_KEY)
      .update(message, 'utf8')
      .digest('hex');

      const signaturesMatch =
      Boolean(expectedSignatureHex) &&
      computedSignatureHex.length === expectedSignatureHex.length &&
      crypto.timingSafeEqual(
      Buffer.from(computedSignatureHex, 'utf8'),
      Buffer.from(expectedSignatureHex, 'utf8'),
      );

      if (!signaturesMatch) {
      return res.status(401).json({ error: 'Invalid signature' });
      }
      ```
    </CodeGroup>
  </Step>

  <Step title="Parse JSON and Branch on Status">
    Only after verification succeeds, parse JSON and branch on top-level **`status`**. Documented example payloads exist for **`success`** and **`failure`**. Timeout and cancellation events also arrive on this endpoint; acknowledge them with **2xx** after you log **`session_id`** / **`encounter_id`**. See [Payload and response](/documentation/webhook/payload-and-response) and [Event types](/documentation/webhook/event-types).

    <CodeGroup>
      ```python Python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      data = json.loads(raw_body)
      status = data.get("status")

      if status == "success":
      session_id = data.get("session_id")
      # Process success notification...
      elif status == "failure":
      error_code = data.get("error_code")
      # Process failure notification...
      else:
      # Timeout / cancellation: log session_id / encounter_id, then ack with 2xx.
      session_id = data.get("session_id")
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      const data = JSON.parse(rawBody) as Record<string, unknown>;
      const status = data.status;

      if (status === 'success') {
      // Process success notification...
      } else if (status === 'failure') {
      // Process failure notification...
      } else {
      // Timeout / cancellation: log session_id / encounter_id, then ack with 2xx.
      console.log('status=', status, 'session_id=', data.session_id);
      }
      ```
    </CodeGroup>
  </Step>

  <Step title="Return 2xx">
    Return **200** (or another **2xx**) so Suki treats the notification as delivered. Invalid signatures should stay **401** (or **400**).

    <CodeGroup>
      ```python Python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      if status == "success":
      return jsonify({"message": "Notification received"}), 200

      if status == "failure":
      return jsonify({"message": "Failure notification received"}), 200

      # Timeout / cancellation (verified delivery): ack so Suki does not retry.
      return jsonify({"message": "Notification acknowledged"}), 200
      ```

      ```typescript TypeScript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
      if (status === 'success') {
      return res.status(200).json({ message: 'Notification received' });
      }

      if (status === 'failure') {
      return res.status(200).json({ message: 'Failure notification received' });
      }

      // Timeout / cancellation (verified delivery): ack so Suki does not retry.
      return res.status(200).json({ message: 'Notification acknowledged' });
      ```
    </CodeGroup>
  </Step>
</Steps>

<span id="full-example" />

## Full code example

<div className="doc-tutorial-full-code" data-doc-tutorial-full-code data-active-lang="python">
  <Columns cols={2}>
    <Tile href="#full-example-python">
      <span className="doc-tutorial-tile-lang-icon" data-icon="python" aria-hidden="true" />
    </Tile>

    <Tile href="#full-example-typescript">
      <span className="doc-tutorial-tile-lang-icon" data-icon="typescript" aria-hidden="true" />
    </Tile>
  </Columns>

  <div id="full-example-python" className="doc-tutorial-lang-panel is-active" data-lang-panel="python">
    ```python Python theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
    # Webhook notification tutorial (Python)
    # Flow: read raw body → verify HMAC-SHA-256 → parse JSON → branch on status → return 2xx
    # Install: pip install flask

    import hashlib
    import hmac
    import json

    from flask import Flask, jsonify, request

    app = Flask(__name__)

    # ---------------------------------------------------------------------------
    # Replace with your partner secret key from onboarding (server-side only)
    # ---------------------------------------------------------------------------
    SECRET_KEY = "<partner_secret_key>"  # Example only — replace with your partner webhook secret from onboarding


    @app.route("/webhooks/notification", methods=["POST"])
    def handle_webhook():
        # 1) Read the raw body exactly as Suki sent it (before JSON parsing).
        raw_body = request.get_data(as_text=True)
        timestamp = request.headers.get("generated-at", "")
        message = timestamp + ":" + raw_body

        # 2) Recompute HMAC-SHA-256 and compare to X-API-Key (constant-time).
        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()

        # Reject missing signatures before compare (empty header must not verify).
        if not expected_signature_hex or not hmac.compare_digest(
            computed_signature_hex, expected_signature_hex
        ):
            return jsonify({"error": "Invalid signature"}), 401

        # 3) Signature matched. Safe to parse JSON and branch on status.
        try:
            data = json.loads(raw_body) if raw_body else None
        except json.JSONDecodeError:
            return jsonify({"error": "Invalid request"}), 400

        if not data or not isinstance(data, dict):
            return jsonify({"error": "Invalid request"}), 400

        status = data.get("status")

        if status == "success":
            session_id = data.get("session_id")
            encounter_id = data.get("encounter_id")
            sessions = data.get("sessions", [])
            additional_info = data.get("additional_info")

            print(f"Session {session_id} completed successfully")
            print(f"Encounter ID: {encounter_id}")
            print(f"Total sessions: {len(sessions)}")
            if additional_info:
                print(f"Additional info: {additional_info}")

            # Optional follow-up links. Each href is a path; combine with your API base URL
            # and call with the correct authentication for Ambient or Form filling.
            links = data.get("_links") or {}
            for key in (
                "contents",
                "encounter_content",
                "structured_data",
                "encounter_structured_data",
                "status",
                "transcripts",
            ):
                for link in links.get(key) or []:
                    print(
                        f"{key}: {link.get('method')} {link.get('href')} - {link.get('name')}"
                    )

            return jsonify({"message": "Notification received"}), 200

        if status == "failure":
            session_id = data.get("session_id")
            encounter_id = data.get("encounter_id")
            error_code = data.get("error_code")
            error_detail = data.get("error_detail")

            print(f"Session {session_id} failed")
            print(f"Encounter ID: {encounter_id}")
            print(f"Error Code: {error_code}")
            print(f"Error Detail: {error_detail}")

            return jsonify({"message": "Failure notification received"}), 200

        # Timeout / cancellation (and any other verified delivery): ack so Suki does not retry.
        # Example JSON for those events is not fully published yet; session_id / encounter_id are documented.
        print(
            f"Acknowledged status={status!r} "
            f"session_id={data.get('session_id')} encounter_id={data.get('encounter_id')}"
        )
        return jsonify({"message": "Notification acknowledged"}), 200


    if __name__ == "__main__":
        # Local smoke test only. Production must use public HTTPS.
        app.run(port=3000)  # Example local port only — use public HTTPS in production
    ```
  </div>

  <div id="full-example-typescript" className="doc-tutorial-lang-panel" data-lang-panel="typescript" hidden>
    ```typescript TypeScript theme={"theme":{"light":"github-dark","dark":"material-theme-darker"}}
    // Webhook notification tutorial (TypeScript / Express)
    // Flow: read raw body → verify HMAC-SHA-256 → parse JSON → branch on status → return 2xx
    // Install: npm install express
    // Prefer Node crypto for HMAC. Keep the partner secret key on your backend only.

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

    const app = express();

    // ---------------------------------------------------------------------------
    // Replace with your partner secret key from onboarding (server-side only)
    // ---------------------------------------------------------------------------
    const SECRET_KEY = '<partner_secret_key>'; // Example only — replace with your partner webhook secret from onboarding

    // express.raw keeps the exact body bytes needed for HMAC verification.
    app.post(
      '/webhooks/notification',
      express.raw({ type: 'application/json' }),
      (req, res) => {
        // 1) Read the raw body exactly as Suki sent it (before JSON parsing).
        const rawBody = Buffer.isBuffer(req.body)
          ? req.body.toString('utf8')
          : String(req.body ?? '');
        const timestamp = String(req.headers['generated-at'] ?? '');
        const message = `${timestamp}:${rawBody}`;

        // 2) Recompute HMAC-SHA-256 and compare to X-API-Key (constant-time).
        const expectedSignatureHex = String(req.headers['x-api-key'] ?? '');
        const computedSignatureHex = crypto
          .createHmac('sha256', SECRET_KEY)
          .update(message, 'utf8')
          .digest('hex');

        // Reject missing signatures before compare (empty header must not verify).
        const signaturesMatch =
          Boolean(expectedSignatureHex) &&
          computedSignatureHex.length === expectedSignatureHex.length &&
          crypto.timingSafeEqual(
            Buffer.from(computedSignatureHex, 'utf8'),
            Buffer.from(expectedSignatureHex, 'utf8'),
          );

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

        // 3) Signature matched. Safe to parse JSON and branch on status.
        let data: Record<string, unknown> | null = null;
        try {
          data = rawBody ? (JSON.parse(rawBody) as Record<string, unknown>) : null;
        } catch {
          return res.status(400).json({ error: 'Invalid request' });
        }

        if (!data) {
          return res.status(400).json({ error: 'Invalid request' });
        }

        const status = data.status;

        if (status === 'success') {
          const sessionId = data.session_id;
          const encounterId = data.encounter_id;
          const sessions = (data.sessions as unknown[]) || [];
          const additionalInfo = data.additional_info;

          console.log(`Session ${sessionId} completed successfully`);
          console.log(`Encounter ID: ${encounterId}`);
          console.log(`Total sessions: ${sessions.length}`);
          if (additionalInfo) {
            console.log('Additional info:', additionalInfo);
          }

          // Optional follow-up links. Each href is a path; combine with your API base URL
          // and call with the correct authentication for Ambient or Form filling.
          const links = (data._links as Record<string, Array<Record<string, unknown>>>) || {};
          for (const key of [
            'contents',
            'encounter_content',
            'structured_data',
            'encounter_structured_data',
            'status',
            'transcripts',
          ] as const) {
            for (const link of links[key] || []) {
              console.log(
                `${key}: ${link.method} ${link.href} - ${link.name}`,
              );
            }
          }

          return res.status(200).json({ message: 'Notification received' });
        }

        if (status === 'failure') {
          console.log(`Session ${data.session_id} failed`);
          console.log(`Encounter ID: ${data.encounter_id}`);
          console.log(`Error Code: ${data.error_code}`);
          console.log(`Error Detail: ${data.error_detail}`);
          return res.status(200).json({ message: 'Failure notification received' });
        }

        // Timeout / cancellation (and any other verified delivery): ack so Suki does not retry.
        // Example JSON for those events is not fully published yet; session_id / encounter_id are documented.
        console.log(
          'Acknowledged status=',
          status,
          'session_id=',
          data.session_id,
          'encounter_id=',
          data.encounter_id,
        );
        return res.status(200).json({ message: 'Notification acknowledged' });
      },
    );

    // Local smoke test only. Production must use public HTTPS.
    app.listen(3000, () => {
      // Example local port only — use public HTTPS in production
      console.log('Webhook receiver listening on http://localhost:3000');
    });
    ```
  </div>
</div>

<span id="troubleshooting" />

## Troubleshooting

<AccordionGroup>
  <Accordion title="Signatures Never Match">
    Verify against the **raw body** before JSON parse. Pretty-print, trim, or auto-parse middleware changes bytes and breaks HMAC.
  </Accordion>

  <Accordion title="Missing `X-API-Key` or Empty Digest">
    Reject with **4xx**. Do not call a constant-time compare on an empty expected signature as a successful verify.
  </Accordion>

  <Accordion title="Suki Keeps Retrying">
    Return **2xx** for accepted notifications, including timeout and cancellation events after you log identifiers (see [Event types](/documentation/webhook/event-types)).
  </Accordion>

  <Accordion title="No Callbacks Arrive">
    Confirm the HTTPS URL is registered with Suki on your partner record ([Configuration](/documentation/webhook/configuration)).
  </Accordion>
</AccordionGroup>

<span id="summary" />

## Summary

In this tutorial, you:

* Built a **`POST /webhooks/notification`** handler.
* Verified each request with HMAC-SHA-256 before parsing JSON.
* Branched on **`success`** or **`failure`** and returned **2xx** for accepted events.

Keep the partner secret server-side only.

<span id="other-tutorials" />

## Other tutorials

Continue with another end-to-end tutorial.

<div className="hp-io-method-grid tut-hub-card-grid">
  <a className="hp-io-method-card tut-hub-method-card" href="/documentation/tutorials/ambient-websocket-code-example">
    <div className="tut-hub-card-media" aria-hidden="true" />

    <div className="hp-io-method-card-body">
      <span className="hp-wn-badge hp-wn-badge-new">Ambient</span>
      <h3 className="hp-io-method-card-title">Build an Ambient Streaming Client</h3>

      <p className="hp-io-method-card-desc">
        Authenticate, create a session, stream PCM audio over WebSocket, and retrieve clinical note results.
      </p>

      <div className="hp-io-method-card-meta tut-hub-card-foot" aria-label="20 min, Intermediate">
        <div className="tut-hub-card-foot-meta">
          <span className="hp-io-method-card-meta-time">20 min</span>
          <span className="tut-hub-level">Intermediate</span>
        </div>
      </div>
    </div>
  </a>

  <a className="hp-io-method-card tut-hub-method-card" href="/documentation/tutorials/form-filling-websocket-code-example">
    <div className="tut-hub-card-media" aria-hidden="true" />

    <div className="hp-io-method-card-body">
      <span className="hp-wn-badge hp-wn-badge-new">Form Filling</span>
      <h3 className="hp-io-method-card-title">Build a Form Filling Session Client</h3>

      <p className="hp-io-method-card-desc">
        Create a Form filling session, send template context, stream audio, and retrieve structured form data.
      </p>

      <div className="hp-io-method-card-meta tut-hub-card-foot" aria-label="20 min, Intermediate">
        <div className="tut-hub-card-foot-meta">
          <span className="hp-io-method-card-meta-time">20 min</span>
          <span className="tut-hub-level">Intermediate</span>
        </div>
      </div>
    </div>
  </a>
</div>
