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.
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"
}'import os
import requests
response = requests.post(
"https://cirrus.pocketful.in/v1/postbacks",
headers={
"Authorization": f"token {os.environ['CIRRUS_API_KEY']}",
},
json={
"url": "http://127.0.0.1:63829/hooks/orders",
"events": [
"order_update",
"trade",
],
"accounts": [
"P1",
],
"description": "Order desk",
},
timeout=10,
)
print(response.status_code, response.json())const response = await fetch('https://cirrus.pocketful.in/v1/postbacks', {
method: 'POST',
headers: {
Authorization: `token ${process.env.CIRRUS_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"url": "http://127.0.0.1:63829/hooks/orders",
"events": [
"order_update",
"trade"
],
"accounts": [
"P1"
],
"description": "Order desk"
}),
});
const body = await response.json();
console.log(response.status, body);{
"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 #
| Event | Sent when |
|---|---|
order_update | An order changed |
trade | An order filled (fully or partly) |
account_alert | Something about an account needs the user |
positions | An account's positions changed |
ping | Test delivery |
trigger_update | A v2 trigger changed (webhooks that opted in only) |
connection_revoked | A user's connection to your partner app ended (partner app webhooks only) |
notification | A 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": { }
}{
"order_tag": "01J9X3K9ZC4N8Q2W6E5R7T1Y3U",
"order_id": "250928000123456",
"account": "AB1234",
"broker": "zerodha",
"instrument_token": "<instrument_token>",
"tradingsymbol": "RELIANCE",
"exchange": "NSE",
"side": "BUY",
"order_type": "LIMIT",
"product": "MIS",
"quantity": 10,
"price": 2500.0,
"trigger_price": null,
"state": "PARTIALLY_FILLED",
"filled_qty": 4,
"average_price": 2499.5,
"status_message": null,
"status_history": [
{"from": "OPEN", "to": "PARTIALLY_FILLED", "at": "2026-09-28T09:15:03.470Z", "message": null}
],
"ltp": 2500.1
}{
"account": "AB1234",
"order_id": "250928000123456",
"order_tag": "01J9X3K9ZC4N8Q2W6E5R7T1Y3U",
"instrument_token": "<instrument_token>",
"tradingsymbol": "RELIANCE",
"side": "BUY",
"quantity": 6,
"price": 2500.83,
"filled_qty": 10,
"order_quantity": 10,
"average_price": 2500.3,
"order_state": "FILLED",
"filled_at": "2026-09-28T09:15:04.120Z"
}{
"activity_id": "01J9X3M0A6B7C8D9E0F1G2H3J4",
"kind": "session_expired",
"account": "AB1234",
"broker": "zerodha",
"summary": "Zerodha login expired: log in to Zerodha again",
"message": "Orders and updates for this account stop until you log in again",
"outcome": "failed",
"at": "2026-09-28T09:20:00.000Z"
}{
"account": "AB1234",
"positions": [{
"instrument_token": "<instrument_token>",
"tradingsymbol": "RELIANCE",
"product": "MIS",
"net_qty": 10,
"average_price": 2500.3,
"pnl": -2.0,
"ltp": 2500.1
}]
}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}")), wherekeyis the base64-decoded part of your secret afterwhsec_. - 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
idyou 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);
// });import base64, hashlib, hmac, time
def verify_webhook(secret: str, headers: dict, raw_body: bytes) -> bool:
webhook_id = headers["webhook-id"]
timestamp = headers["webhook-timestamp"]
signatures = headers.get("webhook-signature", "")
if abs(time.time() - int(timestamp)) > 300:
return False
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{webhook_id}.{timestamp}.".encode() + raw_body
expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
for entry in signatures.split(" "):
version, _, signature = entry.partition(",")
if version == "v1" and hmac.compare_digest(signature, expected):
return True
return Falsesecret whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw
webhook-id msg_p5jXN8AQM9LWM0D4loKWxJek
timestamp 1614265330
body {"test": 2432232314}
signature v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=Answer quickly; retries #
- Answer any
2xxwithin 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
idyou already handled and order bycreated_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_reasontoo_many_failuresorfailing_for_24_hours. - You get a
webhook_disabledentry in your activity log and anaccount_alerton 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:
curl -X POST "https://cirrus.pocketful.in/v1/postbacks/<id>/test" \
-H "Authorization: token $CIRRUS_API_KEY"import os
import requests
response = requests.post(
"https://cirrus.pocketful.in/v1/postbacks/<id>/test",
headers={
"Authorization": f"token {os.environ['CIRRUS_API_KEY']}",
},
timeout=10,
)
print(response.status_code, response.json())const response = await fetch('https://cirrus.pocketful.in/v1/postbacks/<id>/test', {
method: 'POST',
headers: {
Authorization: `token ${process.env.CIRRUS_API_KEY}`,
},
});
const body = await response.json();
console.log(response.status, body);{
"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:
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
}
}'import os
import requests
response = requests.put(
"https://cirrus.pocketful.in/v1/notifications/preferences",
headers={
"Authorization": f"token {os.environ['CIRRUS_API_KEY']}",
},
json={
"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,
},
},
timeout=10,
)
print(response.status_code, response.json())const response = await fetch('https://cirrus.pocketful.in/v1/notifications/preferences', {
method: 'PUT',
headers: {
Authorization: `token ${process.env.CIRRUS_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
"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
}
}),
});
const body = await response.json();
console.log(response.status, body);{
"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
}