Webhook recipes
Alerts tell you what the market is doing. Webhooks and Telegram are how you act on it, with your own tools and your own accounts. We never trade for you, never hold funds and never ask for trade-enabled API keys.
Before you start
- On the Alerts page, add a Custom JSON webhook. VIP Quant and the trial include it.
- Copy the signing secret. It is shown once.
- Press Send test and check your endpoint receives it.
- Point a rule at the destination. The Default alert also goes to it while it is on.
Answer with any 2xx within 8 seconds. Failed sends retry with the same id, so dedupe on it. Each snippet below was run against a real signed delivery. You are responsible for anything you automate from these events.
Events
opportunity.alert: a route matched a rule or the Default alert.rule.idisdefaultfor the Default alert.listing.alert: a market just listed, with one event per listing.opportunity.closed: a route you were alerted on has closed, if the rule asks.position.alert: something happened on a tracked trade.notice.alert: an exchange announced a delisting, a deposit or withdrawal suspension, maintenance or a listing, on the Default alert.trackedsays it is on a trade you track.watch.alert: a wallet you watch received or sent a transfer, on the Default alert.event.txis null when the transaction hash isn't known.alert.test: from Send test, with"test": trueand a fake coin named TEST.
{
"id": "evt_7c1e...",
"type": "listing.alert",
"test": false,
"sentAt": "2026-11-02T10:00:00.000Z",
"rule": { "id": "...", "name": "New listings" },
"listing": { "coin": "ABC", "venue": "Binance", "kind": "spot", "symbol": "ABCUSDT", "listedMs": 1793000000000 },
"window": { "sizeUsd": 1000, "openMs": 4000, "avgWindowMs": null, "avgPeakNetBps": null, "windowSamples": 0 },
"opportunity": { "id": "ABC|Binance:spot|OKX:spot", "coin": "ABC", "...": "..." }
}The full fields are in the webhook docs.
Check the signature
Every request carries X-ArbitrageX-Timestamp and X-ArbitrageX-Signature, which is sha256= plus the hex HMAC-SHA256 of timestamp.rawBody. Check it on the raw bytes, before parsing, as parsed and re-encoded JSON won't match. Refuse timestamps more than 5 minutes from now, to stop replays.
Node.js
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(rawBody, headers, secret) {
const ts = headers["x-arbitragex-timestamp"];
if (!(Math.abs(Date.now() / 1000 - Number(ts)) <= 300)) return false;
const expected =
"sha256=" + createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
const given = Buffer.from(String(headers["x-arbitragex-signature"]));
const wanted = Buffer.from(expected);
return given.length === wanted.length && timingSafeEqual(given, wanted);
}Python
import hashlib
import hmac
import time
def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
ts = headers.get("x-arbitragex-timestamp", "")
try:
if abs(time.time() - int(ts)) > 300:
return False
except ValueError:
return False
signed = ts.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), headers.get("x-arbitragex-signature", "").encode())Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"net/http"
"strconv"
"time"
)
func Verify(rawBody []byte, headers http.Header, secret string) bool {
ts := headers.Get("X-ArbitrageX-Timestamp")
unix, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-unix)) > 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts + "."))
mac.Write(rawBody)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(headers.Get("X-ArbitrageX-Signature")))
}Execution tools
Each example below is the body a tool expects, written against our standard payload: each value in angle brackets is the field of ours to put there. Build it in a Zapier or Make step after a catch webhook, or in your own relay such as the receiver under Run your own script, and post it to the tool's webhook URL. Values in capitals are your own. Only opportunity.alert and listing.alert should reach a tool: filter out test and every other type, so a closed follow-up can't open a trade. We never trade: what a tool does with a message is up to you and the tool.
Each tool changes its own fields over time. Check an example against the tool's current docs before you rely on it.
3Commas signal bot
For a 3Commas signal bot's custom webhook, with its secret and bot UUID from the bot's message settings. A signal bot trades on one exchange, so limit the rule to routes that buy there with the rule's exchange filter.
{
"secret": "YOUR_3COMMAS_SECRET",
"bot_uuid": "YOUR_BOT_UUID",
"max_lag": "300",
"timestamp": "<sentAt>",
"trigger_price": "<opportunity.buy.price>",
"tv_exchange": "<opportunity.buy.venue in capitals, e.g. BINANCE>",
"tv_instrument": "<opportunity.coin>USDT",
"action": "enter_long"
}Hummingbot
Hummingbot takes commands through its own interfaces rather than a webhook, so this goes to a small script strategy of yours, or a bridge to its MQTT interface. Use your connector's own name.
{
"connector": "<opportunity.buy.venue in lower case, e.g. binance>",
"trading_pair": "<opportunity.coin>-USDT",
"side": "BUY",
"price": <opportunity.buy.price>,
"amount_usd": <window.sizeUsd>,
"hedge_connector": "<opportunity.sell.venue in lower case>",
"hedge_price": <opportunity.sell.price>,
"net_bps": <opportunity.netBps>,
"event_id": "<id>"
}Cornix signal
Plain text in Cornix's signal layout, for the webhook address Cornix gives a TradingView bot. Set your own stop, and check the layout against Cornix's current guide.
#<opportunity.coin>/USDT Exchanges: <opportunity.buy.venue> Signal Type: Regular (Long) Entry Targets: 1) <opportunity.buy.price> Take-Profit Targets: 1) <opportunity.sell.price> Stop Targets: 1) YOUR_STOP_PRICE
TradingView-style JSON
The field names a TradingView alert sends, for tools built to receive TradingView alerts.
{
"ticker": "<opportunity.coin>USDT",
"exchange": "<opportunity.buy.venue in capitals>",
"action": "buy",
"price": <opportunity.buy.price>,
"time": "<sentAt>",
"message": "<opportunity.coin> <opportunity.buy.venue> to <opportunity.sell.venue>, net <opportunity.netBps> bps"
}Flat JSON
Every figure at the top level, for a spreadsheet, n8n or a database.
{
"id": "<id>",
"coin": "<opportunity.coin>",
"net_bps": <opportunity.netBps>,
"net_usd_1k": <opportunity.netUsd.1000>,
"max_size_usd": <opportunity.maxSizeUsd>,
"buy_exchange": "<opportunity.buy.venue>",
"buy_price": <opportunity.buy.price>,
"buy_url": "<opportunity.buy.url>",
"sell_exchange": "<opportunity.sell.venue>",
"sell_price": <opportunity.sell.price>,
"sell_url": "<opportunity.sell.url>"
}Other channels
Every channel below is set up on the Alerts page, has a Send test button, and reads like the Telegram message, in the account's language. Rules, cooldowns, quiet hours, the rate limit and auto-pause work the same on all of them.
- Slack (Pro Desk and up): add the Incoming Webhooks app to a channel in Slack and paste the
hooks.slack.comURL it gives you. - ntfy (Starter and up): free notifications on phone and desktop. Paste a topic URL such as
https://ntfy.sh/your-topic, or one on your own server, and subscribe to the same topic in the ntfy app. Anyone who knows a public topic's name can read it, so pick a long random one, or add an access token for a protected topic. Each alert has buttons for both exchanges. - Email digest (Starter and up): one mail to your account's address with every alert since the last, every hour on the hour or every day at 08:00 UTC. Nothing is sent when there is nothing new.
WhatsApp has no free way for a service to message you, so it isn't offered. For anything else, a custom webhook is plain JSON over https.
Post to Slack
A Cloudflare Worker with no server to run. Set ARBITRAGEX_SECRET and SLACK_WEBHOOK_URL as secrets, then use the Worker's URL as your destination. The same code runs anywhere with Web Crypto.
async function verify(rawBody, headers, secret) {
const ts = headers.get("x-arbitragex-timestamp");
if (!(Math.abs(Date.now() / 1000 - Number(ts)) <= 300)) return false;
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"],
);
const given = (headers.get("x-arbitragex-signature") ?? "").replace("sha256=", "");
if (!/^[0-9a-f]{64}$/.test(given)) return false;
const bytes = Uint8Array.from(given.match(/../g), (pair) => parseInt(pair, 16));
return crypto.subtle.verify("HMAC", key, bytes, encoder.encode(`${ts}.${rawBody}`));
}
export default {
async fetch(request, env) {
const rawBody = await request.text();
if (!(await verify(rawBody, request.headers, env.ARBITRAGEX_SECRET))) {
return new Response("bad signature", { status: 401 });
}
const event = JSON.parse(rawBody);
if (event.type === "opportunity.alert" || event.type === "listing.alert") {
const { coin, buy, sell, netBps } = event.opportunity;
await fetch(env.SLACK_WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
text: `${coin}: ${buy.venue} to ${sell.venue}, net ${netBps.toFixed(1)} bps`,
}),
});
}
return new Response(null, { status: 204 });
},
};Keep a spreadsheet
Appends one line per alert to a CSV you can open in any spreadsheet, with Python's standard library alone. Put it behind https: we only send to public https addresses.
import csv
import json
from http.server import BaseHTTPRequestHandler, HTTPServer
import hashlib
import hmac
import time
def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
ts = headers.get("x-arbitragex-timestamp", "")
try:
if abs(time.time() - int(ts)) > 300:
return False
except ValueError:
return False
signed = ts.encode() + b"." + raw_body
expected = "sha256=" + hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), headers.get("x-arbitragex-signature", "").encode())
SECRET = "your signing secret"
seen = set()
class Receiver(BaseHTTPRequestHandler):
def do_POST(self):
raw_body = self.rfile.read(int(self.headers["Content-Length"]))
headers = {key.lower(): value for key, value in self.headers.items()}
if not verify(raw_body, headers, SECRET):
self.send_response(401)
self.end_headers()
return
self.send_response(204)
self.end_headers()
event = json.loads(raw_body)
if event["test"] or event["id"] in seen or "opportunity" not in event:
return
seen.add(event["id"])
row = event["opportunity"]
with open("alerts.csv", "a", newline="") as file:
csv.writer(file).writerow(
[event["sentAt"], event["type"], row["coin"], row["buy"]["venue"], row["sell"]["venue"], row["netBps"]]
)
if __name__ == "__main__":
HTTPServer(("127.0.0.1", 8000), Receiver).serve_forever()Run your own script
A receiver with no dependencies, which answers at once and hands each alert to one function of yours. What that function does is yours to write and yours to answer for. Alerts are estimates from market data, and a route can close before anything you start arrives.
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, headers, secret) {
const ts = headers["x-arbitragex-timestamp"];
if (!(Math.abs(Date.now() / 1000 - Number(ts)) <= 300)) return false;
const expected =
"sha256=" + createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
const given = Buffer.from(String(headers["x-arbitragex-signature"]));
const wanted = Buffer.from(expected);
return given.length === wanted.length && timingSafeEqual(given, wanted);
}
const seen = new Set();
function onAlert(event) {
// Your code goes here. You decide what happens next, and we never see it.
console.log(event.type, event.opportunity.coin, event.opportunity.netBps);
}
export const server = createServer((req, res) => {
const parts = [];
req.on("data", (part) => parts.push(part));
req.on("end", () => {
const rawBody = Buffer.concat(parts).toString("utf8");
if (!verify(rawBody, req.headers, process.env.ARBITRAGEX_SECRET)) {
return res.writeHead(401).end();
}
const event = JSON.parse(rawBody);
// Answer first: anything slower than 8 seconds counts as a failed delivery.
res.writeHead(204).end();
if (event.test || seen.has(event.id)) return;
seen.add(event.id);
if (event.type === "opportunity.alert" || event.type === "listing.alert") onAlert(event);
});
});Zapier and Make
A custom webhook is a plain JSON POST, so a catch webhook URL from either works as a destination. Add a filter step on type and test, then map fields such as opportunity.coin and opportunity.netBps. These steps don't check our signature for you, so treat the catch URL as a password and don't start anything there that you wouldn't let a stranger start.
Telegram
If you only want alerts on your phone, skip all of this. Press Connect Telegram on the Alerts page and then Start in the chat that opens. See the alerts docs.