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

# Webhooks

> Get notified when your users verify or remove a wallet, update their profile, receive the KOL badge or revoke access. Signed with Standard Webhooks.

Webhooks tell your backend when something changes for your users, so you do not have to poll. Set the webhook URL, choose the events and copy the signing secret in the dashboard on the **CabalSpy ID** tab of your instance.

Deliveries follow the [Standard Webhooks](https://www.standardwebhooks.com) specification, so you can verify them with its libraries for any language.

## Events

An event is sent to your instance when the user has signed in to it, your webhook subscribes to that event, and the user granted the scope the event needs. Events are only sent for users within your plan's user limit.

| Event | Scope | When |
| - | - | - |
| `wallet.verified` | `wallets` | The user verified a wallet with an on-chain payment. |
| `wallet.removed` | `wallets` | The user removed a verified wallet. |
| `profile.updated` | `profile` | The user signed in again and their X profile was refreshed. |
| `kol.granted` | `profile` | The user received the KOL badge. |
| `consent.revoked` | `openid` | The user no longer shares data with your app. |
| `thesis.created` | `thesis` | The user posted a [thesis](/cabalspy-id/theses), on your platform or any other. |
| `thesis.reply` | `thesis` | The user replied to a thesis. |
| `thesis.reaction` | `thesis` | The user added or removed a reaction. |
| `webhook.test` | none | You sent a test event from the dashboard. |

## Payload

Every delivery is a `POST` with a JSON body.

<ResponseField name="id" type="string">
  Event id. The same on every retry, deduplicate on it.
</ResponseField>

<ResponseField name="type" type="string">
  Event type, for example `wallet.verified`.
</ResponseField>

<ResponseField name="created_at" type="string">
  When the event happened (ISO 8601).
</ResponseField>

<ResponseField name="sandbox" type="boolean">
  True for events of a sandbox instance.
</ResponseField>

<ResponseField name="data" type="object">
  Event data. Always contains `sub`, the user's id, plus the fields below.
</ResponseField>

<CodeGroup>
  ```json wallet.verified theme={null}
  {
    "id": "6f1d2c9e-8b0a-4f7e-9a51-3c2d1e0f4b7a",
    "type": "wallet.verified",
    "created_at": "2026-10-12T14:21:07.402113+00:00",
    "sandbox": false,
    "data": {
      "sub": "0b7c4a52-3f1e-4f43-9d0a-2b5e7c1d9a11",
      "address": "7j7AA3HZR2zEjwAQEKPFh2qucLY4fqZpB9iodf39w8xW",
      "family": "solana",
      "chain": "solana",
      "tx": "5xr8..."
    }
  }
  ```

  ```json wallet.removed theme={null}
  {
    "id": "...",
    "type": "wallet.removed",
    "created_at": "...",
    "sandbox": false,
    "data": {
      "sub": "0b7c4a52-3f1e-4f43-9d0a-2b5e7c1d9a11",
      "address": "7j7AA3HZR2zEjwAQEKPFh2qucLY4fqZpB9iodf39w8xW",
      "family": "solana"
    }
  }
  ```

  ```json profile.updated theme={null}
  {
    "id": "...",
    "type": "profile.updated",
    "created_at": "...",
    "sandbox": false,
    "data": {
      "sub": "0b7c4a52-3f1e-4f43-9d0a-2b5e7c1d9a11",
      "preferred_username": "MazeCCC",
      "name": "Maze",
      "picture": "https://pbs.twimg.com/profile_images/.../photo.jpg",
      "followers": 48210,
      "following": 812,
      "posts": 15320
    }
  }
  ```

  ```json kol.granted theme={null}
  {
    "id": "...",
    "type": "kol.granted",
    "created_at": "...",
    "sandbox": false,
    "data": {
      "sub": "0b7c4a52-3f1e-4f43-9d0a-2b5e7c1d9a11",
      "preferred_username": "MazeCCC"
    }
  }
  ```

  ```json consent.revoked theme={null}
  {
    "id": "...",
    "type": "consent.revoked",
    "created_at": "...",
    "sandbox": false,
    "data": {
      "sub": "0b7c4a52-3f1e-4f43-9d0a-2b5e7c1d9a11",
      "reason": "user_removed_access"
    }
  }
  ```
</CodeGroup>

`thesis.created` carries the new thesis in `data.thesis`, the same object as in [List theses](/cabalspy-id/theses-list#response):

```json thesis.created theme={null}
{
  "id": "...",
  "type": "thesis.created",
  "created_at": "...",
  "sandbox": false,
  "data": {
    "sub": "0b7c4a52-3f1e-4f43-9d0a-2b5e7c1d9a11",
    "thesis": {
      "id": "8d1f5a2e-6c4b-4e0f-9a7d-1b2c3d4e5f60",
      "chain": "solana",
      "mint": "3Pkrq4MmLvDXyn1fa3sz5MekKRkZkc1iLDcz5AzWpump",
      "kind": "thesis",
      "body": "Strong community, still early.",
      "stance": "bull",
      "via": "Example Terminal",
      "...": "the full thesis object"
    }
  }
}
```

Thesis events go to the apps of the person who acted: the author of a thesis or reply, or the person who reacted. `thesis.reply` carries the reply in `data.reply`. `thesis.reaction` carries the post and the change:

<CodeGroup>
  ```json thesis.reply theme={null}
  {
    "id": "...",
    "type": "thesis.reply",
    "created_at": "...",
    "sandbox": false,
    "data": {
      "sub": "5e2d8c41-7a9b-4c3e-b1f0-6d5e4c3b2a19",
      "reply": {
        "id": "2a9c7e10-4b3d-4f5e-8a6b-7c8d9e0f1a2b",
        "kind": "reply",
        "parent_id": "8d1f5a2e-6c4b-4e0f-9a7d-1b2c3d4e5f60",
        "body": "Nice call",
        "...": "the full reply object"
      }
    }
  }
  ```

  ```json thesis.reaction theme={null}
  {
    "id": "...",
    "type": "thesis.reaction",
    "created_at": "...",
    "sandbox": false,
    "data": {
      "sub": "5e2d8c41-7a9b-4c3e-b1f0-6d5e4c3b2a19",
      "thesis_id": "8d1f5a2e-6c4b-4e0f-9a7d-1b2c3d4e5f60",
      "chain": "solana",
      "mint": "3Pkrq4MmLvDXyn1fa3sz5MekKRkZkc1iLDcz5AzWpump",
      "emoji": "🚀",
      "on": true,
      "reactions": { "like": 5, "🚀": 3 }
    }
  }
  ```
</CodeGroup>

`thesis.reaction` is only sent when the reaction actually changed, `on` is `true` for added and `false` for removed.

`consent.revoked` carries a `reason`: `user_removed_access` (the user removed your app), `blocked_by_platform` (you blocked the user in the dashboard) or `account_deleted` (the user deleted their CabalSpy ID). Delete the user's data you got from CabalSpy ID when you receive it. Their refresh tokens are already revoked.

## Headers

| Header | Value |
| - | - |
| `webhook-id` | The event id, the same on every retry. |
| `webhook-timestamp` | Unix time in seconds when the delivery was sent. |
| `webhook-signature` | `v1,<base64 signature>`. During a secret roll, one signature per active secret, separated by spaces. |
| `CSID-Event-Type` | The event type. |
| `Content-Type` | `application/json` |
| `User-Agent` | `CabalSpy-ID-Webhooks/1` |

## Verify the signature

The signature is an HMAC-SHA256 over `<webhook-id>.<webhook-timestamp>.<raw body>`, keyed with the base64-decoded part of your secret after `whsec_`. Always verify against the raw request body, and reject deliveries whose timestamp is more than 5 minutes off.

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from "node:crypto";
  import express from "express";

  const SECRET = process.env.CSID_WEBHOOK_SECRET; // whsec_...
  const app = express();

  function verify(rawBody, headers) {
    const id = headers["webhook-id"];
    const ts = headers["webhook-timestamp"];
    const sigs = (headers["webhook-signature"] || "").split(" ");
    if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

    const key = Buffer.from(SECRET.replace(/^whsec_/, ""), "base64");
    const expected = crypto.createHmac("sha256", key)
      .update(`${id}.${ts}.`).update(rawBody).digest();

    return sigs.some((s) => {
      const [version, value] = s.split(",");
      if (version !== "v1" || !value) return false;
      const given = Buffer.from(value, "base64");
      return given.length === expected.length && crypto.timingSafeEqual(given, expected);
    });
  }

  app.post("/webhooks/csid", express.raw({ type: "application/json" }), (req, res) => {
    if (!verify(req.body, req.headers)) return res.sendStatus(400);
    const event = JSON.parse(req.body);
    // dedupe on event.id, then handle event.type
    res.sendStatus(204);
  });
  ```

  ```python Python theme={null}
  import base64, hashlib, hmac, json, os, time
  from flask import Flask, request

  SECRET = os.environ["CSID_WEBHOOK_SECRET"]  # whsec_...
  app = Flask(__name__)

  def verify(body: bytes, headers) -> bool:
      msg_id = headers.get("webhook-id", "")
      ts = headers.get("webhook-timestamp", "")
      if not msg_id or not ts.isdigit() or abs(time.time() - int(ts)) > 300:
          return False
      key = base64.b64decode(SECRET.removeprefix("whsec_"))
      expected = hmac.new(key, f"{msg_id}.{ts}.".encode() + body, hashlib.sha256).digest()
      for sig in headers.get("webhook-signature", "").split():
          version, _, value = sig.partition(",")
          if version == "v1" and hmac.compare_digest(base64.b64decode(value), expected):
              return True
      return False

  @app.post("/webhooks/csid")
  def csid_webhook():
      if not verify(request.get_data(), request.headers):
          return "", 400
      event = json.loads(request.get_data())
      # dedupe on event["id"], then handle event["type"]
      return "", 204
  ```

  ```python Standard Webhooks library theme={null}
  from standardwebhooks.webhooks import Webhook

  wh = Webhook(os.environ["CSID_WEBHOOK_SECRET"])  # whsec_...
  event = wh.verify(request.get_data(), dict(request.headers))  # raises on a bad signature
  ```
</CodeGroup>

## Delivery and retries

Webhook URLs must use `https` and resolve to a public address. Answer with any `2xx` status within 10 seconds, ideally right away, and do the work afterwards. Redirects are not followed.

Any other answer, a timeout or a connection error is retried after 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 3 hours, 6 hours, 12 hours and 24 hours, about two days in total. After the last attempt the delivery is marked failed. The dashboard shows the recent deliveries of your instance with their status.

Deliveries can arrive more than once and out of order. Deduplicate on `webhook-id` and, when order matters, compare `created_at`.

## Rolling the secret

When you roll the secret in the dashboard, the old secret stays valid for a while. During that time every delivery carries one signature per active secret in `webhook-signature`, separated by spaces, so both your old and new deployment accept it.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.