Webhooks
MARBIN tells your systems when something happens to your customers: a POST of a JSON event to each endpoint you register in the partner portal (API & webhooks), for the events you choose.
| Event | When |
customer.joined | A customer finished registering (waiting for approval) |
customer.approved | A customer was approved: trading starts |
account.paused | A customer's trading was paused |
account.resumed | A customer's trading was resumed |
trade.executed | A buy or a sale was filled at the broker |
stop.hit | A holding was sold by its protective stop |
alert | Something on a customer's account needs a look |
customer.removed | A customer was removed (or their invitation cancelled): MARBIN stops trading for them |
engine.paused | MARBIN paused all trading (every customer): no new buys; stops stay at the broker |
engine.resumed | MARBIN resumed all trading |
test.ping | The partner portal's Send a test event |
What an event looks like
The same envelope for every event: id (unique; a retry carries the same one), type, created (Unix seconds), api_version, bank (your id at MARBIN), livemode (false from the sandbox) and data. A customer is named by customer_id (cus_…, random, the same in the customer API and your reports) and customer_ref, your own reference for them -- never a name, email, phone number or balance. engine.paused / engine.resumed name no customer: MARBIN paused or resumed trading for everyone (stops stay at the broker). Events never carry MARBIN's rules or thresholds: a sale's reason is one of manual, not_found_at_broker, rebalance, shariah, sold_by_customer, stop, take_profit, trailing_stop, withdrawal; an alert has a kind and a fixed message. practice is true for a practice (demo) account.
customer.approved
{
"id": "evt_4f0c2b9a1d7e33c6a8b51e02",
"type": "customer.approved",
"created": 1792569601,
"api_version": "1",
"bank": "your-bank",
"livemode": true,
"data": {
"customer": {
"customer_id": "cus_7d1e04b9a2c35f68",
"customer_ref": "YB-000123"
},
"status": "approved",
"by": "bank"
}
}trade.executed
{
"id": "evt_4f0c2b9a1d7e33c6a8b51e02",
"type": "trade.executed",
"created": 1792569601,
"api_version": "1",
"bank": "your-bank",
"livemode": true,
"data": {
"customer": {
"customer_id": "cus_7d1e04b9a2c35f68",
"customer_ref": "YB-000123"
},
"practice": false,
"trade": {
"side": "sell",
"symbol": "MSFT",
"quantity": 3.0,
"price": 412.5,
"currency": "USD",
"executed_at": 1792569600,
"reason": "stop"
}
}
}stop.hit
{
"id": "evt_4f0c2b9a1d7e33c6a8b51e02",
"type": "stop.hit",
"created": 1792569601,
"api_version": "1",
"bank": "your-bank",
"livemode": true,
"data": {
"customer": {
"customer_id": "cus_7d1e04b9a2c35f68",
"customer_ref": "YB-000123"
},
"practice": false,
"trade": {
"side": "sell",
"symbol": "MSFT",
"quantity": 3.0,
"price": 412.5,
"currency": "USD",
"executed_at": 1792569600,
"reason": "stop"
}
}
}alert
{
"id": "evt_4f0c2b9a1d7e33c6a8b51e02",
"type": "alert",
"created": 1792569601,
"api_version": "1",
"bank": "your-bank",
"livemode": true,
"data": {
"customer": {
"customer_id": "cus_7d1e04b9a2c35f68",
"customer_ref": "YB-000123"
},
"practice": false,
"alert": {
"kind": "cash_short",
"message": "The account's cash is below zero: no buys until it is back above zero.",
"at": 1792569600
}
}
}engine.paused
{
"id": "evt_4f0c2b9a1d7e33c6a8b51e02",
"type": "engine.paused",
"created": 1792569601,
"api_version": "1",
"bank": "your-bank",
"livemode": true,
"data": {
"status": "paused",
"by": "marbin",
"at": 1792569600
}
}Headers
| Header | Value |
MARBIN-Signature | t=<unix seconds>,v1=<hex> |
MARBIN-Event-Id | the event's id |
MARBIN-Event-Type | its type |
Content-Type | application/json |
User-Agent | MARBIN-Webhooks/1 |
Checking the signature
Each endpoint has its own signing secret (whsec_…), shown once when you add it. v1 is the HMAC-SHA256, in hex, of the timestamp, a full stop and the raw body: HMAC_SHA256(secret, t + "." + body). Recompute it over the body exactly as received, compare in constant time, and refuse a timestamp more than 5 minutes from your clock -- that stops a captured event being replayed. Several v1 values may come (a future secret change): accept a match with any.
Python
import hashlib
import hmac
import time
def verify_marbin(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
"""True when a MARBIN webhook is genuine and fresh.
secret: the endpoint's signing secret (whsec_...), from your secrets store.
header: the MARBIN-Signature request header, "t=<unix seconds>,v1=<hex>".
body: the raw request body, exactly as received -- before any JSON parsing.
"""
items = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
ts = next((v for k, v in items if k == "t"), "")
signatures = [v for k, v in items if k == "v1"]
if not ts.isdigit() or not signatures:
return False
if abs(time.time() - int(ts)) > tolerance: # too old (or from the future): a replay
return False
expected = hmac.new(secret.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)# Flask, for example: check first, answer 2xx quickly, then do the work (an event may come more than once).
@app.post("/marbin/events")
def marbin_events():
body = request.get_data() # raw bytes
if not verify_marbin(MARBIN_SECRET, request.headers.get("MARBIN-Signature", ""), body):
return "", 400
event = json.loads(body)
if already_seen(event["id"]): # idempotency: retries carry the same id
return "", 200
queue_for_processing(event)
return "", 200Node.js
const crypto = require("crypto");
// True when a MARBIN webhook is genuine and fresh.
// secret: the endpoint's signing secret (whsec_...); header: the MARBIN-Signature header;
// rawBody: the request body as a Buffer, exactly as received (e.g. express.raw({ type: "application/json" })).
function verifyMarbin(secret, header, rawBody, toleranceSec = 300) {
const items = String(header || "").split(",").map((p) => {
const i = p.indexOf("=");
return i < 0 ? ["", ""] : [p.slice(0, i).trim(), p.slice(i + 1).trim()];
});
const ts = (items.find(([k]) => k === "t") || [])[1] || "";
const signatures = items.filter(([k]) => k === "v1").map(([, v]) => v);
if (!/^\d+$/.test(ts) || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false; // a replay
const expected = crypto.createHmac("sha256", secret).update(ts + ".").update(rawBody).digest("hex");
return signatures.some((s) => s.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
}
module.exports = { verifyMarbin };Answering, retries and order
- Answer with any 2xx within 10 seconds; do the work afterwards. Redirects aren't followed.
- Anything else (or no answer) is retried after 1 min, 5 min, 30 min, 2 h, 5 h, 10 h, 24 h -- 8 tries over about two days, then given up. Every try is in the portal's Delivery log.
- Events can arrive more than once and out of order: keep the ids you've handled, and use
created(or the API) for the latest state. - An endpoint must be
https://on the public internet. - Send a test event posts a signed
test.pingto one endpoint at once and shows the answer. - Adding, changing or removing an endpoint is in your audit log; changing where events go will need two approvers (one at your bank, one at MARBIN) once dual control is switched on.