Guide

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

Request
POST, JSON body, one event per call
Your answer
any 2xx within 10 s
On failure
8 retries over about 24 h
Signature
HMAC-SHA256 over t.body

Set 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.

Delivery
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…
Body - deposit.status
{
  "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-Id equals id in the body: the event id, stable across retries. It is your deduplication key.
  • X-Webhook-Event equals type. Route on it before parsing the whole body if you like.
  • X-Webhook-Timestamp equals the t inside 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:

  1. Split the header

    Take t and every v1 value. During a secret rotation there are two v1= entries - one per secret.

  2. Check the time

    Refuse the call when |now − t| is above 300 seconds. That stops replays of an old, valid delivery.

  3. Compute your own signature

    HMAC-SHA256(secret, "<t>.<raw body>") as lowercase hex. The dot is literal; the body is the exact bytes received.

  4. Compare in constant time

    Accept when your value equals any of the v1 values, using a constant-time comparison. Answer 401 otherwise 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.

Receiver
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
});

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 200 to events you do not handle.

Do not

  • Call other services or the Partner API inside the request.
  • Answer 4xx for "unknown type" - that only burns retries.
  • Rely on the order of arrival (see below).

#Retries

Octolit platform POST event · waits up to 10 s for your answer Your endpoint answer 2xx at once, do the work afterwards first try 503 +1 min retry 1 503 +5 min retry 2 503 +30 min retry 3 200 +1 h retry 4 — +2 h retry 5 — +6 h retry 6 — +14 h retry 7 — ~24 h final — a 2xx answer ends the series

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.

  1. retry 11 minute
  2. retry 25 minutes
  3. retry 330 minutes
  4. retry 41 hour
  5. retry 52 hours
  6. retry 66 hours
  7. retry 714 hours
  8. 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 fresh t / 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:

  1. Process each event id once

    Before doing the work, insert id into 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: answer 200 and stop.

  2. Treat events as signals, not as state

    Use created_at to 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

  1. 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.

  2. 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.

  3. Remove the old secret after 24 hours

    Once the overlap is over, deliveries carry one signature again. Drop WEBHOOK_SECRET_OLD.

Lost the secret?
Rotate. There is no way to read the current secret again; the overlap means nothing is missed while you deploy.

#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.

TypeWhen
client.createdA client was created
client.updatedA client's tracked fields changed (`data.changed_fields`)
client.agent_changedThe assigned agents changed (`data.previous_agents`)
client.deletedA client was deleted (`data.client` = id + deleted_at only)
client.loginA client signed in (password, link or single sign-on)
client.inactiveA client has not signed in for N days (the brand's "inactive after" setting)
account.createdA trading account was opened
trade.openedA position was opened
trade.modifiedA position's stop loss / take profit / amount changed
trade.closedA position was closed (`data.position.closed_by` = user, tp, sl, stop_out, admin, ...)
demo.first_tradeThe client's first trade on a demo account
margin.callThe client's margin level fell to the brand's margin call level
stop.outPositions were closed by the stop-out rule
price_alert.triggeredA client's price alert fired (the level was crossed)
deposit.statusA deposit changed status (pending, approved, declined, cancelled)
withdrawal.statusA withdrawal changed status (pending, approved, declined, cancelled)
webhook.testThe "Send test event" button of the Octolit CRM

#Go-live checklist

  • HTTPS endpoint with a valid certificate; answers 200 to webhook.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 / 5xx in your logs and on a paused endpoint in the CRM.