Webhooks
Octolit calls your endpoint when something happens to a client, an account, a trade or a payment. This guide builds a receiver that survives production: it verifies every call, answers fast, never does the same work twice and keeps working through a secret rotation.
#How delivery works
POST, JSON body, one event per call2xx within 10 st.bodySet the endpoint up in the Octolit CRM under Settings › API & Webhooks › Webhooks: the URL (HTTPS), the events you want, and the secret - whsec_ plus 40 characters, shown once. Trade, margin and demo events also need the brand switch Trading webhooks. Press Send test event to receive a webhook.test while you build.
#Step 1: receive the request
Every delivery looks like this. The body is the event envelope; the headers tell you which event it is and carry the signature.
POST /webhooks/octolit HTTP/1.1
Host: your-site.example
Content-Type: application/json
User-Agent: TradingCRM-Webhooks/1
X-Webhook-Id: evt_01JCX7R0M6YQ3A8ZK5R4T2N9P1
X-Webhook-Event: deposit.status
X-Webhook-Timestamp: 1762853400
X-Webhook-Signature: t=1762853400,v1=5f1c0b8e9d3a…{
"id": "evt_01JCX7R0M6YQ3A8ZK5R4T2N9P1",
"type": "deposit.status",
"created_at": "2026-11-11T09:30:00.000000Z",
"company_id": 10,
"data": {
"client": {
"id": 1042,
"external_id": "cust_10042",
"brand": {
"id": 1042,
"name": "Main"
}
},
"transaction": {
"id": 1042,
"kind": "deposit",
"status": "pending",
"previous_status": "pending",
"amount": "100.00",
"currency": "USD",
"account_id": 55,
"method": "card",
"at": "2026-11-11T09:30:00.000000Z"
}
}
}X-Webhook-Idequalsidin the body: the event id, stable across retries. It is your deduplication key.X-Webhook-Eventequalstype. Route on it before parsing the whole body if you like.X-Webhook-Timestampequals thetinside the signature header.- Keep the raw body bytes. Frameworks that parse JSON first and re-serialise it change whitespace and key order, and the signature no longer matches.
#Step 2: verify the signature
The header is X-Webhook-Signature: t=<unix seconds>,v1=<hex>. The rule, exactly:
Split the header
Take
tand everyv1value. During a secret rotation there are twov1=entries - one per secret.Check the time
Refuse the call when
|now − t|is above 300 seconds. That stops replays of an old, valid delivery.Compute your own signature
HMAC-SHA256(secret, "<t>.<raw body>")as lowercase hex. The dot is literal; the body is the exact bytes received.Compare in constant time
Accept when your value equals any of the
v1values, using a constant-time comparison. Answer401otherwise and log the event id.
The skeletons below do all four steps, then deduplicate by event id, queue the work and answer 200. Drop the one in your language into your framework and replace store and queue with your own.
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const app = express();
// two secrets during a rotation, one otherwise
const SECRETS = [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_SECRET_OLD].filter(Boolean);
function verify(raw, header) {
const parts = header.split(',').map((p) => p.split('='));
const t = Number(parts.find(([k]) => k === 't')?.[1]);
const given = parts.filter(([k]) => k === 'v1').map(([, v]) => v);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false; // too old or too new
return SECRETS.some((secret) => {
const mine = createHmac('sha256', secret).update(`${t}.${raw}`).digest('hex');
return given.some((g) => g.length === mine.length && timingSafeEqual(Buffer.from(g), Buffer.from(mine)));
});
}
app.post('/webhooks/octolit', express.raw({ type: 'application/json' }), async (req, res) => {
const raw = req.body.toString('utf8'); // the bytes as sent, not re-encoded
if (!verify(raw, req.get('X-Webhook-Signature') || '')) return res.status(401).end();
const event = JSON.parse(raw);
if (!(await store.firstTime(event.id))) return res.status(200).end(); // duplicate delivery: already done
await queue.add(event); // do the work later
res.status(200).end(); // answer within 10 s
});import hmac, hashlib, json, os, time
from flask import Flask, request, abort
app = Flask(__name__)
# two secrets during a rotation, one otherwise
SECRETS = [s for s in (os.environ.get("WEBHOOK_SECRET"), os.environ.get("WEBHOOK_SECRET_OLD")) if s]
def verify(raw: bytes, header: str) -> bool:
parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
t = next((v for k, v in parts if k == "t"), None)
given = [v for k, v in parts if k == "v1"]
if not t or abs(time.time() - int(t)) > 300: # too old or too new
return False
for secret in SECRETS:
mine = hmac.new(secret.encode(), f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
if any(hmac.compare_digest(mine, g) for g in given):
return True
return False
@app.post("/webhooks/octolit")
def hook():
raw = request.get_data() # the bytes as sent, not re-encoded
if not verify(raw, request.headers.get("X-Webhook-Signature", "")):
abort(401)
event = json.loads(raw)
if not store.first_time(event["id"]): # duplicate delivery: already done
return "", 200
queue.enqueue(event) # do the work later
return "", 200 # answer within 10 s<?php
// two secrets during a rotation, one otherwise
$secrets = array_filter([getenv('WEBHOOK_SECRET'), getenv('WEBHOOK_SECRET_OLD')]);
$raw = file_get_contents('php://input'); // the bytes as sent, not re-encoded
$header = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$t = null; $given = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = array_pad(explode('=', $part, 2), 2, '');
if ($k === 't') $t = (int) $v;
if ($k === 'v1') $given[] = $v;
}
$ok = false;
if ($t && abs(time() - $t) <= 300) { // too old or too new otherwise
foreach ($secrets as $secret) {
$mine = hash_hmac('sha256', $t . '.' . $raw, $secret);
foreach ($given as $g) if (hash_equals($mine, $g)) $ok = true;
}
}
if (!$ok) { http_response_code(401); exit; }
$event = json_decode($raw, true);
if (!$store->firstTime($event['id'])) { http_response_code(200); exit; } // duplicate delivery
$queue->push($event); // do the work later
http_response_code(200); // answer within 10 susing System.Security.Cryptography;
using System.Text;
using System.Text.Json;
// two secrets during a rotation, one otherwise
var secrets = new[] { Environment.GetEnvironmentVariable("WEBHOOK_SECRET"), Environment.GetEnvironmentVariable("WEBHOOK_SECRET_OLD") }
.Where(s => !string.IsNullOrEmpty(s)).ToArray();
static bool Verify(string raw, string header, string[] secrets)
{
var parts = header.Split(',').Select(p => p.Split('=', 2)).Where(p => p.Length == 2).ToList();
var t = parts.FirstOrDefault(p => p[0] == "t")?[1];
var given = parts.Where(p => p[0] == "v1").Select(p => p[1]).ToList();
if (t == null || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - long.Parse(t)) > 300) return false; // too old or too new
foreach (var secret in secrets)
{
var mine = Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{t}.{raw}"))).ToLowerInvariant();
if (given.Any(g => CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(g), Encoding.UTF8.GetBytes(mine)))) return true;
}
return false;
}
app.MapPost("/webhooks/octolit", async (HttpRequest req, IEventStore store, IQueue queue) =>
{
using var reader = new StreamReader(req.Body, Encoding.UTF8);
var raw = await reader.ReadToEndAsync(); // the bytes as sent, not re-encoded
if (!Verify(raw, req.Headers["X-Webhook-Signature"].ToString(), secrets)) return Results.Unauthorized();
var id = JsonDocument.Parse(raw).RootElement.GetProperty("id").GetString()!;
if (!await store.FirstTimeAsync(id)) return Results.Ok(); // duplicate delivery: already done
await queue.EnqueueAsync(raw); // do the work later
return Results.Ok(); // answer within 10 s
});import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.*;
import java.util.stream.Stream;
@RestController
public class OctolitWebhook {
// two secrets during a rotation, one otherwise
private final List<String> secrets = Stream.of(System.getenv("WEBHOOK_SECRET"), System.getenv("WEBHOOK_SECRET_OLD"))
.filter(Objects::nonNull).toList();
@PostMapping("/webhooks/octolit")
public ResponseEntity<Void> receive(@RequestBody byte[] raw, @RequestHeader("X-Webhook-Signature") String header) throws Exception {
if (!verify(raw, header)) return ResponseEntity.status(401).build(); // raw = the bytes as sent
JsonNode event = new ObjectMapper().readTree(raw);
if (!store.firstTime(event.get("id").asText())) return ResponseEntity.ok().build(); // duplicate delivery
queue.add(event); // do the work later
return ResponseEntity.ok().build(); // answer within 10 s
}
boolean verify(byte[] raw, String header) throws Exception {
String t = null;
List<String> given = new ArrayList<>();
for (String part : header.split(",")) {
String[] kv = part.split("=", 2);
if (kv.length != 2) continue;
if (kv[0].equals("t")) t = kv[1];
if (kv[0].equals("v1")) given.add(kv[1]);
}
if (t == null || Math.abs(Instant.now().getEpochSecond() - Long.parseLong(t)) > 300) return false; // too old or too new
for (String secret : secrets) {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
String mine = HexFormat.of().formatHex(mac.doFinal(raw));
for (String g : given)
if (MessageDigest.isEqual(mine.getBytes(StandardCharsets.UTF_8), g.getBytes(StandardCharsets.UTF_8))) return true;
}
return false;
}
}package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// two secrets during a rotation, one otherwise
var secrets = []string{os.Getenv("WEBHOOK_SECRET"), os.Getenv("WEBHOOK_SECRET_OLD")}
func verify(raw []byte, header string) bool {
var t string
var given []string
for _, part := range strings.Split(header, ",") {
k, v, ok := strings.Cut(part, "=")
if !ok {
continue
}
switch k {
case "t":
t = v
case "v1":
given = append(given, v)
}
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil || abs(time.Now().Unix()-ts) > 300 { // too old or too new
return false
}
for _, secret := range secrets {
if secret == "" {
continue
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(raw)
mine := hex.EncodeToString(mac.Sum(nil))
for _, g := range given {
if hmac.Equal([]byte(mine), []byte(g)) {
return true
}
}
}
return false
}
func abs(n int64) int64 { if n < 0 { return -n }; return n }
func hook(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // the bytes as sent, not re-encoded
if !verify(raw, r.Header.Get("X-Webhook-Signature")) {
w.WriteHeader(http.StatusUnauthorized)
return
}
var event struct {
ID string `json:"id"`
Type string `json:"type"`
}
_ = json.Unmarshal(raw, &event)
if !store.FirstTime(event.ID) { // duplicate delivery: already done
w.WriteHeader(http.StatusOK)
return
}
queue.Push(raw) // do the work later
w.WriteHeader(http.StatusOK) // answer within 10 s
} Check a real delivery against your secret with the webhook verifier before you deploy.
#Step 3: answer fast
Octolit waits 10 seconds for your answer. Anything other than a 2xx inside that window - a timeout, a 5xx, a connection error - counts as a failed attempt and starts the retry schedule.
Do
- Verify, store the event id, push the event on a queue, answer
200. - Do the real work (e-mails, ledger updates, API calls) from the queue.
- Answer
200to events you do not handle.
Do not
- Call other services or the Partner API inside the request.
- Answer
4xxfor "unknown type" - that only burns retries. - Rely on the order of arrival (see below).
#Retries
Each failed attempt is repeated after a growing pause (1 min, 5 min, 30 min, 1 h, 2 h, 6 h, 14 h, then a final try about 24 h after the first). The first 2xx stops the series.
- retry 11 minute
- retry 25 minutes
- retry 330 minutes
- retry 41 hour
- retry 52 hours
- retry 66 hours
- retry 714 hours
- retry 8final (about 24 h after the first try)
- Each pause gets 0 - 10 % random jitter, so retries from many events do not land at the same second.
- An endpoint that keeps failing is paused; the CRM shows it and you switch it back on after the fix. Events raised while it was paused are in the delivery log and can be re-sent.
- Retries carry the same
id, the same body and a fresht/ signature.
#Duplicates and order
An event may arrive more than once (your 200 got lost, a retry crossed with it) and events may arrive out of order (a retry of an older event after a newer one). Two rules make this harmless:
Process each event id once
Before doing the work, insert
idinto a table with a unique constraint (keep rows for at least 48 hours - longer than the retry window). If the insert fails, the event was already handled: answer200and stop.Treat events as signals, not as state
Use
created_atto ignore an older event about the same object when you already applied a newer one. For anything that must be exact - a balance, a position, a transaction status - read the current value from the Partner API (GET …/balance,GET …/positions) instead of reconstructing it from the stream.
#Rotate the secret without downtime
Press "Rotate secret" in the CRM
A new secret is shown once. For the next 24 hours every delivery is signed with both secrets: the header carries two
v1=values.Deploy the new secret next to the old one
Your verifier (as in the skeletons above) tries both. Any delivery that matches either secret is valid.
Remove the old secret after 24 hours
Once the overlap is over, deliveries carry one signature again. Drop
WEBHOOK_SECRET_OLD.
#Delivery log and resend
The CRM keeps every attempt for 30 days: the event, the time, your status code, the answer time and the error, if any. From the log an operator can re-send a failed event by hand after your fix - the same id again, which is why the deduplication above matters. Use the log to confirm your endpoint answers in well under 10 seconds, and to find the one event that failed during a deploy.
#Events
Each type has its own data object, documented with a full example in the reference. Every event about a client carries data.client.external_id - your own id - so you never need the CRM's id to match it to your records.
| Type | When |
|---|---|
client.created | A client was created |
client.updated | A client's tracked fields changed (`data.changed_fields`) |
client.agent_changed | The assigned agents changed (`data.previous_agents`) |
client.deleted | A client was deleted (`data.client` = id + deleted_at only) |
client.login | A client signed in (password, link or single sign-on) |
client.inactive | A client has not signed in for N days (the brand's "inactive after" setting) |
account.created | A trading account was opened |
trade.opened | A position was opened |
trade.modified | A position's stop loss / take profit / amount changed |
trade.closed | A position was closed (`data.position.closed_by` = user, tp, sl, stop_out, admin, ...) |
demo.first_trade | The client's first trade on a demo account |
margin.call | The client's margin level fell to the brand's margin call level |
stop.out | Positions were closed by the stop-out rule |
price_alert.triggered | A client's price alert fired (the level was crossed) |
deposit.status | A deposit changed status (pending, approved, declined, cancelled) |
withdrawal.status | A withdrawal changed status (pending, approved, declined, cancelled) |
webhook.test | The "Send test event" button of the Octolit CRM |
#Go-live checklist
- HTTPS endpoint with a valid certificate; answers
200towebhook.test. - Signature verified on the raw body with a 300 s time window and a constant-time compare.
- Work runs from a queue; the request handler finishes in well under 10 s.
- Event ids stored with a unique constraint; a re-sent event does nothing twice.
- Both secrets accepted during rotation; the old one removed afterwards.
- Unknown event types answered with
200. - Alerting on
401/5xxin your logs and on a paused endpoint in the CRM.