Skip to content
Cirrus home
Sign in

Guide

Webhooks and notifications

Get a signed HTTPS call when orders, trades, positions or triggers change, verify it, and choose what reaches Telegram.

A webhook is a URL on your server. Cirrus sends it a signed POST each time something you subscribed to happens, so you do not need to poll.

Add a webhook #

Add one on API access or with the API from the signed-in app (webhooks are managed by you, not by API keys). The signing secret is shown once: store it.

POST
curl -X POST "https://cirrus.pocketful.in/v1/postbacks" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "http://127.0.0.1:63829/hooks/orders",
    "events": [
      "order_update",
      "trade"
    ],
    "accounts": [
      "P1"
    ],
    "description": "Order desk"
  }'
Response · 201
{
  "status": "success",
  "data": {
    "id": "pb_01m3pgps59en5pjhyj92tbsq65",
    "url": "http://127.0.0.1:63829/hooks/orders",
    "events": [
      "order_update",
      "trade"
    ],
    "accounts": [
      "P1"
    ],
    "description": "Order desk",
    "enabled": true,
    "disabled_reason": null,
    "disabled_at": null,
    "consecutive_failures": 0,
    "last_success_at": null,
    "last_failure_at": null,
    "previous_secret_expires_at": null,
    "secret_rotated_at": null,
    "created_at": "2026-09-29T12:02:21.993Z",
    "updated_at": "2026-09-29T12:02:21.993Z",
    "secret": "whsec_kmM4VkgFv6UVjwsTRs15AdSvI23Js5hRdI8FN5OrYlQ=",
    "note": "Store the secret now: it is not shown again."
  },
  "error": null
}

Events #

EventSent when
order_updateAn order changed
tradeAn order filled (fully or partly)
account_alertSomething about an account needs the user
positionsAn account's positions changed
pingTest delivery
trigger_updateA v2 trigger changed (webhooks that opted in only)
connection_revokedA user's connection to your partner app ended (partner app webhooks only)
notificationA notification (webhooks chosen in notification preferences only)

Every body has id, type, created_at and data:

{
  "id": "evt_5f0c2a7d9b1e4c3a8f6d2b0e9a7c5d31",
  "type": "order_update",
  "created_at": "2026-09-28T09:15:03.482Z",
  "data": { }
}

Verify the signature #

Check every delivery before you trust it. Deliveries follow the Standard Webhooks spec, so its libraries work too.

  • The signature is base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw body}")), where key is the base64-decoded part of your secret after whsec_.
  • Verify against the raw body, before any JSON parsing. Accept when any listed signature matches; compare in constant time.
  • Reject timestamps more than 5 minutes from your clock, and ignore an id you already handled.
  • Deliveries follow the Standard Webhooks specification, so its libraries (Node, Python, Go, Java, Ruby, PHP, Rust) verify them as they are.
const crypto = require("crypto");

function verifyWebhook(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"] || "";
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest("base64");
  return signatures.split(" ").some((entry) => {
    const [version, signature = ""] = entry.split(",");
    return (
      version === "v1" &&
      signature.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))
    );
  });
}

// Express: verify the raw body, then parse it.
// app.post("/hooks/trading", express.raw({ type: "application/json" }), (req, res) => {
//   if (!verifyWebhook(process.env.WEBHOOK_SECRET, req.headers, req.body.toString("utf8")))
//     return res.sendStatus(400);
//   const event = JSON.parse(req.body);
//   res.sendStatus(204);
// });

Answer quickly; retries #

  • Answer any 2xx within 5 seconds. Do slow work after answering.
  • Anything else, or no answer, is retried after 30 s, 2 min, 10 min, 30 min, 1 h and 2 h, then dropped.
  • Deliveries can repeat and arrive out of order: ignore an id you already handled and order by created_at.
  • A webhook whose deliveries keep failing (50 failed attempts in a row, or failing for 24 hours without a success) is turned off: enabled: false, disabled_reason too_many_failures or failing_for_24_hours.
  • You get a webhook_disabled entry in your activity log and an account_alert on your other webhooks. Fix the receiver, then turn it back on (PATCH {"enabled": true} or Resume on API access). Events from while it was off are not replayed.

Send yourself a signed ping any time to test your receiver:

POST
curl -X POST "https://cirrus.pocketful.in/v1/postbacks/<id>/test" \
  -H "Authorization: token $CIRRUS_API_KEY"
Response · 200
{
  "status": "success",
  "data": {
    "event_id": "evt_cc2f23548b726e8a6a8bf980be542540",
    "delivered": true,
    "status_code": 200,
    "duration_ms": 0,
    "error_kind": null
  },
  "error": null
}

Notifications #

Notifications are plain-English messages about triggers, orders, strategies and your accounts. They reach the app inbox, your own Telegram (link it in the app) and, if you choose, your webhooks as the notification event. Pick which events go where:

PUT
curl -X PUT "https://cirrus.pocketful.in/v1/notifications/preferences" \
  -H "Authorization: token $CIRRUS_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "telegram": {
      "enabled": true,
      "events": {
        "order_filled": true,
        "trigger_ended": false
      },
      "scope": {
        "mode": "selected",
        "strategies": [
          "nifty-momentum"
        ],
        "triggers": []
      }
    },
    "app_push": {
      "enabled": true,
      "events": {},
      "scope": {
        "mode": "all"
      }
    },
    "webhook": {
      "enabled": false
    }
  }'
Response · 200
{
  "status": "success",
  "data": {
    "telegram": {
      "enabled": true,
      "events": {
        "account_setup": true,
        "channel_broken": true,
        "daily_summary": true,
        "entry_failed": true,
        "exit_filled": true,
        "exit_placed": true,
        "exit_refused": true,
        "live_updates": true,
        "order_filled": true,
        "order_rejected": true,
        "order_unconfirmed": true,
        "protection_failed": true,
        "risk_breached": true,
        "session_expired": true,
        "strategy_armed": false,
        "strategy_entered": true,
        "strategy_error": true,
        "strategy_exited": true,
        "strategy_kill_switch": true,
        "strategy_refused": true,
        "strategy_safety_pause": true,
        "trigger_ended": false,
        "trigger_fired": true,
        "trigger_refused": true,
        "webhook_disabled": true
      },
      "scope": {
        "mode": "selected",
        "strategies": [
          "nifty-momentum"
        ],
        "triggers": []
      }
    },
    "app_push": {
      "enabled": true,
      "events": {
        "account_setup": true,
        "channel_broken": true,
        "daily_summary": true,
        "entry_failed": true,
        "exit_filled": true,
        "exit_placed": true,
        "exit_refused": true,
        "live_updates": true,
        "order_filled": false,
        "order_rejected": true,
        "order_unconfirmed": true,
        "protection_failed": true,
        "risk_breached": true,
        "session_expired": true,
        "strategy_armed": false,
        "strategy_entered": true,
        "strategy_error": true,
        "strategy_exited": true,
        "strategy_kill_switch": true,
        "strategy_refused": true,
        "strategy_safety_pause": true,
        "trigger_ended": false,
        "trigger_fired": true,
        "trigger_refused": true,
        "webhook_disabled": true
      },
      "scope": {
        "mode": "all",
        "strategies": [],
        "triggers": []
      }
    },
    "webhook": {
      "enabled": false,
      "events": {
        "account_setup": true,
        "channel_broken": true,
        "daily_summary": true,
        "entry_failed": true,
        "exit_filled": true,
        "exit_placed": true,
        "exit_refused": true,
        "live_updates": true,
        "order_filled": false,
        "order_rejected": true,
        "order_unconfirmed": true,
        "protection_failed": true,
        "risk_breached": true,
        "session_expired": true,
        "strategy_armed": false,
        "strategy_entered": true,
        "strategy_error": true,
        "strategy_exited": true,
        "strategy_kill_switch": true,
        "strategy_refused": true,
        "strategy_safety_pause": true,
        "trigger_ended": false,
        "trigger_fired": true,
        "trigger_refused": true,
        "webhook_disabled": true
      },
      "scope": {
        "mode": "all",
        "strategies": [],
        "triggers": []
      }
    }
  },
  "error": null
}

In the reference