---
name: knot-webhooks
description: >
  Build and secure a server endpoint that receives Knot webhooks: configure URLs per
  environment, allowlist Knot's IP, return 200 fast and process asynchronously, survive
  retries idempotently, verify the Knot-Signature HMAC, read session metadata, and route
  events. Use when: (1) "set up Knot webhooks", (2) "verify Knot-Signature",
  (3) "webhook signature" is failing or mismatched, (4) "not receiving webhooks" from Knot,
  (5) "handle Knot events" or map an event to the right handler, (6) testing webhooks in
  the Knot development environment.
metadata:
  author: Knot
  version: "1.0"
---

# Knot Webhooks

Knot sends server-side events (authentication, card updates, new transactions, and more) as `POST` requests with a raw JSON body. Webhooks, not SDK callbacks, are the source of truth for backend state. Full reference: [Webhooks](https://docs.knotapi.com/webhooks.md). For shared setup (credentials, base URLs, `Knot-Version`), see the `knot` skill.

If the Knot docs MCP server (`https://docs.knotapi.com/mcp`) is connected, search it for an event's payload schema before writing a handler.

## 1. Configure endpoints

- Add URLs in [Dashboard → Developers → Webhooks](https://dashboard.knotapi.com/developers/webhooks). Development and production are configured separately, with **up to 10 URLs per environment**.
- URLs must look like `http(s)://(www.)domain.com/` and, if `https`, have a valid SSL certificate.
- Knot sends webhooks from `35.232.249.218/32` in all environments. If you allowlist source IPs at a firewall or WAF, allow this one. Knot says the IP may change and will notify you in advance, so keep it in config rather than code.

## 2. Respond fast, process asynchronously

If your endpoint returns a non-200 status or doesn't respond within **10 seconds**, Knot retries **up to two more times**, a few minutes apart.

- Read the raw body, verify the signature, persist or enqueue the event, then return `200` immediately. Do the real work (API calls, DB writes, notifications) in a background job.
- Because of retries, the same event can arrive more than once. The payload has no documented delivery ID, so make handlers idempotent:
  - Store a hash of the raw body and skip exact repeats.
  - Write state with upserts keyed on natural IDs (`task_id`, `session_id`, `external_user_id` + `merchant.id`), not blind inserts.
  - For data-available events (`NEW_TRANSACTIONS_AVAILABLE`, `NEW_SUBSCRIPTIONS_AVAILABLE`, etc.), treat the event as a signal to fetch. Fetching twice should be harmless.

## 3. Verify `Knot-Signature`

Knot signs every webhook. The docs say verification is optional, but it is strongly recommended. It is the only way to prove a request came from Knot.

Algorithm (exact order):

1. Read the `Knot-Signature` header.
2. Build these key/value pairs **in this order**:
   - `Content-Length`: byte length of the entire raw request body
   - `Content-Type`: `application/json`
   - `Encryption-Type`: `HMAC-SHA256` (also sent as the `Encryption-Type` header)
   - `event`: the body's `event` field
   - `session_id`: the body's `session_id` field. **Omit both the key and the value when the body has no `session_id`.** Many events have none, including `MERCHANT_STATUS_UPDATE`, `NEW_TRANSACTIONS_AVAILABLE`, and `ACCOUNT_LOGIN_REQUIRED`.
3. Join them all with `|`:
   `Content-Length|178|Content-Type|application/json|Encryption-Type|HMAC-SHA256|event|CARD_UPDATED|session_id|fb5aa994-ed1c-4c3e-b29a-b2a53222e584`
4. Compute an HMAC-SHA256 of that string, keyed with your **client secret** for that environment, and base64-encode it.
5. Compare it to the header in constant time.

Use the raw bytes for the length, never a re-serialized `JSON.stringify(req.body)`. Re-serializing changes the byte count and breaks the signature.

### Node.js / TypeScript (Express)

```ts
import crypto from "node:crypto";
import express from "express";

const app = express();
const KNOT_SECRET = process.env.KNOT_SECRET!; // secret for this environment

function knotSignature(raw: Buffer, body: any): string {
  // Knot signs these fixed Content-Type / Encryption-Type values
  const parts = [
    "Content-Length", String(raw.length), // raw byte length
    "Content-Type", "application/json",
    "Encryption-Type", "HMAC-SHA256",
    "event", String(body.event),
  ];
  if (body.session_id != null) parts.push("session_id", String(body.session_id)); // omit when absent
  return crypto.createHmac("sha256", KNOT_SECRET).update(parts.join("|")).digest("base64");
}

function safeEqual(a: string, b: string): boolean {
  const x = Buffer.from(a), y = Buffer.from(b);
  return x.length === y.length && crypto.timingSafeEqual(x, y);
}

// express.raw keeps the body as a Buffer so the byte length is exact
app.post("/webhooks/knot", express.raw({ type: "*/*" }), (req, res) => {
  const raw = req.body as Buffer;
  let body: any;
  try { body = JSON.parse(raw.toString("utf8")); } catch { return res.sendStatus(400); }

  const received = String(req.headers["knot-signature"] ?? "");
  if (!safeEqual(knotSignature(raw, body), received)) return res.sendStatus(401);

  res.sendStatus(200);  // ack within 10s
  enqueue(body, raw);   // your queue or background job; dedupe there
});
```

### Python (Flask)

```python
import base64, hashlib, hmac, json, os
from flask import Flask, request

app = Flask(__name__)
KNOT_SECRET = os.environ["KNOT_SECRET"].encode()  # secret for this environment

def knot_signature(raw: bytes, body: dict) -> str:
    # Knot signs these fixed Content-Type / Encryption-Type values
    parts = [
        "Content-Length", str(len(raw)),  # raw byte length
        "Content-Type", "application/json",
        "Encryption-Type", "HMAC-SHA256",
        "event", str(body.get("event")),
    ]
    if body.get("session_id") is not None:  # omit when absent
        parts += ["session_id", str(body["session_id"])]
    digest = hmac.new(KNOT_SECRET, "|".join(parts).encode(), hashlib.sha256).digest()
    return base64.b64encode(digest).decode()

@app.post("/webhooks/knot")
def knot_webhook():
    raw = request.get_data()  # raw bytes, read before any JSON parsing
    try:
        body = json.loads(raw)
    except ValueError:
        return "", 400
    received = request.headers.get("Knot-Signature", "")
    if not hmac.compare_digest(knot_signature(raw, body), received):
        return "", 401
    enqueue(body, raw)  # your queue or background job; dedupe there
    return "", 200
```

### Signature mismatch checklist

- Is a JSON body parser running before your handler? Read raw bytes instead.
- Are you using the secret for the environment that sent the webhook? Development and production secrets differ.
- Did you include `session_id` when the body has none, or leave it out when the body has one?
- Did you sign exactly `application/json` and `HMAC-SHA256`? A proxy that adds `; charset=utf-8` to the header doesn't change the signed value.
- Do you base64-encode the HMAC, not hex-encode it?

## 4. Session metadata

Attach `metadata` when calling `POST /session/create`, or pass it to the SDK. Client-side values override server-side values for duplicate keys. Knot echoes it back in `data.metadata` on webhooks for that session.

- Limits: max **10 keys**, string keys and values, max **500 characters** per value.
- Present only when you provided it.
- Use it to correlate events with your internal IDs, or to carry a token (e.g. a JWE) that your handler checks before accepting the payload.

## 5. Route events

Every payload has `event`, `timestamp` (ms), and usually `external_user_id` and `merchant`. Session-scoped events also carry `session_id` and `task_id`. Route on `event`, and handle only the events for the products you use. Each product's quickstart lists its webhook events.

Product-specific timing: for CardSwitcher, call Switch Card (`POST /card`) within **15 seconds** of `AUTHENTICATED` (see [Launch checklist](https://docs.knotapi.com/launch-checklist.md)).

### Unknown events

Knot treats new webhook events and new payload properties as backwards compatible, so they ship without a version bump ([Versioning](https://docs.knotapi.com/api-reference/versioning.md)). Your router must:

- Log unrecognized `event` values and return `200`. Never return an error for them, or Knot will retry.
- Parse payloads leniently. Ignore unknown fields rather than failing strict schema validation.

```ts
const handlers: Record<string, (e: any) => Promise<void>> = {
  AUTHENTICATED: onAuthenticated,
  CARD_UPDATED: onCardUpdated,
  NEW_TRANSACTIONS_AVAILABLE: onNewTransactions,
};
async function route(e: any) {
  const h = handlers[e.event];
  if (!h) return log.info("unhandled knot event", e.event); // still acked with 200
  await h(e);
}
```

## 6. Test in development

- Configure a **development** webhook URL first. For local testing, expose your server through a public HTTPS tunnel.
- `POST https://development.knotapi.com/development/accounts/link` links a merchant account without the SDK and fires real webhooks. It sends `AUTHENTICATED`, then `CARD_UPDATED`/`CARD_FAILED` after you switch the card (`card_switcher: true` + `card_id`), `NEW_TRANSACTIONS_AVAILABLE` (`transactions`), or `NEW_SUBSCRIPTIONS_AVAILABLE` (sample subscription merchants only). See [Link Account](https://docs.knotapi.com/api-reference/development/link-account.md).
- Or go through the SDK with `user_good` / `pass_good`. `pass_otp` triggers the OTP flow ([CardSwitcher testing](https://docs.knotapi.com/card-switcher/testing.md)).

## Not receiving webhooks?

1. Is the URL saved under the **same environment** the session was created in?
2. Is the endpoint publicly reachable over valid HTTPS (no self-signed cert, no auth wall)?
3. Is `35.232.249.218` allowed through your firewall, WAF, or bot protection?
4. Does the endpoint return `200` within 10s? Slow or erroring responses are retried at most twice more.
5. Is a signature check rejecting valid requests? Log the computed and received values in development and walk the mismatch checklist above.
6. Did the event actually happen? For example, `NEW_TRANSACTIONS_AVAILABLE` needs a `transaction_link` session and a linked account.
