A relay between broker accounts that provides clear, common interfaces to communicate with different brokers through a single interface layer — deployed to a DigitalOcean droplet with a single make deploy.
Warning
This project is under active development and not yet ready for prime time. You're welcome to use it, but expect frequent breaking changes.
Broker APIs are fragmented — each has its own data formats, auth patterns, and delivery mechanisms. Every integration rebuilds the same plumbing: polling, parsing, dedup, webhook delivery.
RelayPort abstracts this with a relay adapter pattern: one generic engine handles polling, dedup, aggregation, and webhook delivery; broker-specific adapters handle the API quirks. Adding a broker is writing one adapter.
Currently supports IBKR (Interactive Brokers) via the Flex Web Service and Kraken (crypto exchange) via REST + WebSocket v2. Deploys to a DigitalOcean droplet from $4/month, with:
- A relay engine that checks for trade fills and sends them to your webhook URL via a common payload format
- Automatic HTTPS via Caddy + Let's Encrypt
- SQLite dedup so each fill is delivered exactly once
- A debug webhook inbox for testing without hitting production services
- Multi-account support within each broker adapter
- Optional real-time listeners — IBKR via ibkr_bridge WebSocket, Kraken via native WS v2 executions channel
Scope: Broker → User (trade fill events). Future plans include User → Broker (order placement).
For IBKR order placement, see the companion project ibkr_bridge — it runs the IB Gateway and exposes an HTTP API + WebSocket event stream.
- Quick Start
- API Endpoints
- Architecture
- Configuration
- Webhook Payload
- IBKR Setup
- Kraken Setup
- On-Demand Poll
- Watermark Management
- Pause & Resume
- Security
- Current Status
- Contributing
A fully-fledged IBKR relay server on DigitalOcean in under 2 minutes:
git clone → make setup → set env vars → make deploy → trade fills hit your webhook
- Docker Desktop (includes Docker Compose v2)
- Terraform
- A DigitalOcean API token
- An IBKR account with Flex Web Service enabled (see IBKR Setup)
macOS / Linux — install Docker Desktop and Terraform in one line:
# macOS (Homebrew)
brew install --cask docker && brew install terraform
# Linux (apt) — see links above for other distros
sudo apt-get install docker-compose-plugin && sudo apt-get install terraformWindows — install Docker Desktop and Terraform manually or via
winget install Docker.DockerDesktop Hashicorp.Terraform.
# 1. Clone and set up
git clone https://github.com/tradegist/ibkr_relay.git
cd ibkr_relay
make setup # Create .venv, install deps, copy env templates
# 2. Configure (3 files)
# .env → RELAYS=ibkr, NOTIFIERS=webhook, TARGET_WEBHOOK_URL, WEBHOOK_SECRET
# .env.droplet → DEPLOY_MODE=standalone, DO_API_TOKEN
# .env.relays → IBKR_FLEX_TOKEN, IBKR_FLEX_QUERY_ID
# 3. Deploy — provisions a droplet, starts all containers
make deploy
# That's it. Trade fills now arrive at your webhook URL.
# DNS and HTTPS can be configured later — the relay polls and delivers
# webhooks immediately, no inbound access needed.
# Tear down when done
make destroyAll endpoints require Authorization: Bearer <API_TOKEN> header (except health).
POST /relays/{relay_name}/poll/{poll_idx}
No body required. Immediately polls the broker for new fills and sends them to the configured webhook. poll_idx is 1-based (e.g. /relays/ibkr/poll/1 for the primary poller, /relays/ibkr/poll/2 for the second account).
GET /health
Returns {"status": "ok"}. No auth required.
┌──────────────────────────────────────────────────────────┐
│ DigitalOcean Droplet │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ caddy (reverse proxy + auto HTTPS) │ │
│ │ trade.example.com → relays:8000 │ │
│ │ Ports: 80 (HTTP→redirect), 443 (HTTPS) │ │
│ └──────────────┬──────────────────┬────────────┘ │
│ │ │ │
│ ┌──────────────▼───────┐ ┌───────▼─────────────────┐ │
│ │ relays │ │ debug (optional) │ │
│ │ Registry → Adapters │ │ Webhook payload inbox │ │
│ │ Poller engine │ │ POST/GET/DELETE │ │
│ │ Listener engine │ └─────────────────────────┘ │
│ │ HTTP API │ │
│ │ SQLite dedup │ │
│ └──────────────────────┘ │
│ │
│ Firewall: SSH from deployer IP only │
│ HTTP/HTTPS open (Caddy auto-redirects HTTP → HTTPS) │
└──────────────────────────────────────────────────────────┘
Three containers in a single Docker network (debug is optional):
caddy— Caddy 2 reverse proxy with automatic HTTPS via Let's Encrypt. Routes/relays/*to the relays service.relays— Multi-relay service that loads broker adapters via the registry pattern. Runs pollers (periodic Flex fetch), an optional real-time WebSocket listener, and an HTTP API. Each broker adapter is a plugin that provides fetch/parse callbacks — the generic engines handle dedup, aggregation, notification, and scheduling. Does not hold any broker sessions — trade normally via web/mobile.debug— Optional debug webhook inbox. Captures webhook payloads for inspection during development. Enabled whenDEBUG_WEBHOOK_PATHis set.
Dedup guarantee. The relay uses a SQLite dedup database so each fill is delivered at most once under normal operation. In the rare event of an internal crash between webhook delivery and dedup bookkeeping, a fill may be sent a second time. Design your webhook consumer to be idempotent (e.g. deduplicate on
execId).
Not required to get started. The relay's core job is entirely outbound — poll the broker, send webhooks. It works immediately after
make deploywithout DNS or HTTPS. A domain is only needed for inbound access: on-demand polls via the API, health checks, and the debug webhook inbox.
Set SITE_DOMAIN and API_TOKEN in .env when you're ready for inbound access. Caddy uses the domain to automatically provision a TLS certificate from Let's Encrypt.
- Point the domain to the droplet's reserved IP as an A record:
trade.example.com A 1.2.3.4 - Set it in
.env:SITE_DOMAIN=trade.example.com - Start the stack — Caddy will automatically obtain and renew the certificate.
Can I use just an IP address? No. Let's Encrypt does not issue certificates for bare IP addresses.
Set DROPLET_SIZE in .env.droplet to control the droplet size. The relay is lightweight — the smallest droplet ($4/month) works fine:
DROPLET_SIZE=s-1vcpu-512mb # $4/month (default)Configuration is split across three environment files. Templates are in env_examples/ — make setup copies them to .<name> if missing.
| Variable | Required | Default | Description |
|---|---|---|---|
SITE_DOMAIN |
Yes | — | Domain for the relay API (see Domains & HTTPS) |
API_TOKEN |
Yes | — | Bearer token for /relays/* endpoints (openssl rand -hex 32) |
RELAYS |
No | — | Comma-separated relay adapters (e.g. ibkr, ibkr,kraken). Empty = API server only |
NOTIFIERS |
No | — | Active notification backends (e.g. webhook). Empty = dry-run |
TARGET_WEBHOOK_URL |
No | — | Webhook endpoint (empty = log-only dry-run) |
WEBHOOK_SECRET |
No | — | HMAC-SHA256 key for signing payloads (required if NOTIFIERS=webhook) |
POLL_INTERVAL |
No | 600 |
Flex poll interval (seconds). IBKR limit: 10 req/minute per token (shared across query IDs) — do not set below 420 (7 min) |
POLLER_ENABLED |
No | true |
Set to false to disable the poller globally (relay override: {RELAY}_POLLER_ENABLED) |
LISTENER_ENABLED |
No | — | Set to true to enable real-time WS listeners globally; IBKR requires ibkr_bridge, Kraken does not |
LISTENER_DEBOUNCE_MS |
No | 0 |
Milliseconds to buffer fills before flushing |
IBKR_LISTENER_EXEC_EVENTS_ENABLED |
No | false |
Enable execDetailsEvent webhooks (2x volume, lower latency) |
DEBUG_WEBHOOK_PATH |
No | — | Route webhooks to debug inbox instead of TARGET_WEBHOOK_URL (see Debug Webhook Inbox) |
MAX_DEBUG_WEBHOOK_PAYLOADS |
No | 100 |
Max payloads stored in the debug inbox (hard max: 150, FIFO eviction) |
DEBUG_LOG_LEVEL |
No | INFO |
Set to DEBUG to include full payload+headers in docker logs debug |
FX_RATES_ENABLED |
No | false |
Attach fxRate/fxRateBase/fxRateSource to each Trade (see FX Rate Enrichment) |
FX_RATES_BASE_CURRENCY |
No* | — | ISO-4217 base currency (required when FX_RATES_ENABLED=true) |
FX_RATE_API_KEY |
No | — | exchangerate-api.com key — enables historical rates |
FX_CACHE_RETENTION_DAYS |
No | 730 |
Days to retain cached historical rates in the meta DB |
RESEND_API_KEY |
No* | — | Resend API key — enables Operational Alerts when set with ALERT_REPORT_EMAIL_TO |
ALERT_REPORT_EMAIL_TO |
No* | — | Recipient email for delivery-failure alerts (required to enable alerts) |
ALERT_EMAIL_FROM |
No | onboarding@resend.dev |
Optional sender address (must be verified in Resend for production use) |
ALERT_COOLDOWN_MINUTES |
No | 60 |
Suppress duplicate alerts for the same destination within this window |
| Variable | Required | Default | Description |
|---|---|---|---|
DEPLOY_MODE |
Yes | — | standalone (own droplet via Terraform) or shared (existing droplet) |
DO_API_TOKEN |
Yes* | — | DigitalOcean API token (standalone only — can be removed after first deploy) |
DROPLET_IP |
Yes* | — | Droplet IP (from Terraform output in standalone; provided by host in shared) |
SSH_KEY |
No | ~/.ssh/relayport |
SSH key path — shared mode only. Standalone auto-generates. |
DROPLET_SIZE |
No | s-1vcpu-512mb |
Override droplet size slug |
| Variable | Required | Description |
|---|---|---|
IBKR_FLEX_TOKEN |
Yes | Flex Web Service token (from Client Portal) |
IBKR_FLEX_QUERY_ID |
Yes | Flex Query ID (Trade Confirmation or Activity) |
IBKR_ACCOUNT_TIMEZONE |
No | IANA tz for IBKR timestamps (e.g. America/New_York). Default: UTC. Invalid value fails boot |
IBKR_FLEX_QUERY_ID_2 |
No | Second account query ID (enables second poller within same relay) |
IBKR_FLEX_TOKEN_2 |
No | Second account token (defaults to primary if omitted) |
IBKR_FLEX_LOOKBACK_DAYS |
No | Override saved query Period with "last N calendar days" via the p URL param (1-365). Do not set when using a Trade Confirmation query with a "Today" period — it overrides that period to a historical window, causing intraday fills to be missed. Only applicable for Activity Flex queries. |
IBKR_NOTIFIERS |
No | Override NOTIFIERS for IBKR relay only |
IBKR_TARGET_WEBHOOK_URL |
No | Override TARGET_WEBHOOK_URL for IBKR relay only |
IBKR_WEBHOOK_SECRET |
No | Override WEBHOOK_SECRET for IBKR relay only |
IBKR_POLL_INTERVAL |
No | Override POLL_INTERVAL for IBKR relay only. Minimum recommended: 420 (7 min) — see rate-limit note in POLL_INTERVAL |
IBKR_POLLER_ENABLED |
No | Override POLLER_ENABLED for IBKR relay only |
| Kraken | ||
KRAKEN_API_KEY |
Yes* | Kraken API key (required when kraken is in RELAYS) |
KRAKEN_API_SECRET |
Yes* | Kraken API secret, base64-encoded (required with API key) |
KRAKEN_LISTENER_ENABLED |
No | Enable WS v2 real-time listener (default: false) |
KRAKEN_LISTENER_DEBOUNCE_MS |
No | Buffer fills N ms before dispatching webhook (default: 0) |
KRAKEN_LOOKBACK_DAYS |
No | How far back each REST poll looks for trades, in days (default: 30, min: 1) |
KRAKEN_POLL_INTERVAL |
No | Override POLL_INTERVAL for Kraken relay only |
KRAKEN_POLLER_ENABLED |
No | Override POLLER_ENABLED for Kraken relay only |
KRAKEN_NOTIFIERS |
No | Override NOTIFIERS for Kraken relay only |
KRAKEN_TARGET_WEBHOOK_URL |
No | Override TARGET_WEBHOOK_URL for Kraken relay only |
KRAKEN_WEBHOOK_SECRET |
No | Override WEBHOOK_SECRET for Kraken relay only |
KRAKEN_NOTIFY_RETRIES |
No | Override NOTIFY_RETRIES for Kraken relay only |
KRAKEN_NOTIFY_RETRY_DELAY_MS |
No | Override NOTIFY_RETRY_DELAY_MS for Kraken relay only |
Adding a new relay's vars requires no compose changes — just add prefixed vars to .env.relays.
When orders fill, the relay POSTs a JSON payload with all trades batched into a single request:
{
"relay": "ibkr",
"type": "trades",
"data": [
{
"orderId": "684196618",
"symbol": "AAPL",
"assetClass": "equity",
"side": "buy",
"orderType": "market",
"price": 254.6,
"volume": 1.0,
"cost": 254.6,
"fee": 1.0,
"fillCount": 1,
"execIds": ["0001f4e8.67890abc.01.01"],
"timestamp": "2026-04-02T13:30:08",
"source": "flex",
"raw": {
"accountId": "UXXXXXXX",
"assetCategory": "STK",
"currency": "USD",
"commission": -1.0,
"commissionCurrency": "USD",
"tradeDate": "20260402",
"dateTime": "20260402;093008",
"orderTime": "20260401;183713",
"orderType": "MKT",
"listingExchange": "NASDAQ",
"exchange": "IBDARK",
"underlyingSymbol": "AAPL"
}
}
],
"errors": []
}The envelope uses a discriminated union pattern — relay identifies the broker and type identifies the event kind. Consumers should type their variables as WebhookPayload (the union). Currently the only variant is WebhookPayloadTrades (type: "trades"); new event types (e.g. orders, positions) will be added as new variants.
All broker adapters use the same CommonFill model. The data array contains Trade objects with these guaranteed fields:
| Field | Type | Description |
|---|---|---|
orderId |
string |
Permanent order identifier (unique per account) |
symbol |
string |
Instrument symbol. For options, this is the OCC ticker with spaces removed for URL-friendliness (e.g. AVGO260620C00200000); the underlying is in option.rootSymbol |
assetClass |
AssetClass |
"equity", "option", "crypto", "future", "forex", or "other" |
side |
"buy" | "sell" |
Trade direction (lowercase) |
orderType |
OrderType | null |
Normalized: "market", "limit", "stop", "stop_limit", "trailing_stop", or null |
price |
number |
VWAP when aggregated, single fill price otherwise |
volume |
number |
Sum of fill quantities |
cost |
number |
Total cost (sum of fills) |
fee |
number |
Total fees/commissions (always positive — amount paid) |
fillCount |
number |
Number of fills aggregated into this trade |
execIds |
string[] |
One execution ID per fill (for tracing back to individual fills) |
timestamp |
string |
Latest fill timestamp. Canonical form: YYYY-MM-DDTHH:MM:SS, always UTC, no Z suffix, no fractional seconds |
source |
string |
Origin: "flex" (IBKR Flex poll), "execDetailsEvent" / "commissionReportEvent" (IBKR WS), "rest_poll" (Kraken REST), "ws_execution" (Kraken WS) |
currency |
string | null |
ISO-4217 currency of the asset traded (e.g. "USD" for AAPL). null when the broker doesn't expose it |
option |
OptionContract | null |
Option contract metadata. Populated when assetClass == "option", null for all other instruments. See Option contracts below |
fxRate |
number | null |
FX rate such that cost * fxRate = cost_in_base. Only populated when FX_RATES_ENABLED=true (see FX Rate Enrichment) |
fxRateBase |
string | null |
ISO-4217 base currency the fxRate converts to |
fxRateSource |
string | null |
"historical" or "latest" — whether the rate is the trade-day rate (paid API) or most recent (keyless) |
raw |
object |
Original broker-specific payload (all fields, unmodified) |
The raw object preserves the full broker-specific data. For IBKR Flex, this includes ~100 XML attributes (account info, security details, financial fields, dates). Consumers should treat raw as opaque broker data — the CommonFill fields above are the stable contract.
The errors array contains warnings about parse problems — it is empty when everything parsed cleanly.
When assetClass == "option", the option object is populated (non-null) and contains:
| Field | Type | Description |
|---|---|---|
rootSymbol |
string |
Underlying ticker (e.g. "AVGO") |
strike |
number |
Strike price |
expiryDate |
string |
Expiry date in ISO format (YYYY-MM-DD) |
type |
"call" | "put" |
Option type |
Example — IBKR option trade (AVGO call, sold via Flex):
{
"relay": "ibkr",
"type": "trades",
"data": [
{
"orderId": "684196620",
"symbol": "AVGO260620C00200000",
"assetClass": "option",
"side": "sell",
"orderType": "limit",
"price": 5.2,
"volume": 1.0,
"cost": 520.0,
"fee": 0.65,
"fillCount": 1,
"execIds": ["0001f4e8.67890abc.02.01"],
"timestamp": "2026-04-02T14:05:00",
"source": "flex",
"currency": "USD",
"option": {
"rootSymbol": "AVGO",
"strike": 200.0,
"expiryDate": "2026-06-20",
"type": "call"
},
"raw": { "...": "..." }
}
],
"errors": []
}Rows with assetClass == "option" where option metadata is missing or invalid are skipped and surfaced in the errors array rather than emitted with an incomplete option object. This means any trade that reaches your webhook with assetClass == "option" is guaranteed to have a non-null option field — the invariant is enforced by the parsers rather than by the type schema (which models option as OptionContract | null to cover non-option assets).
The payload is signed with HMAC-SHA256. Verify using the X-Signature-256 header:
# Python
import hashlib, hmac
expected = hmac.new(secret.encode(), body.encode(), hashlib.sha256).hexdigest()
assert header_value == f"sha256={expected}"// Node.js
const crypto = require("crypto");
const expected = crypto.createHmac("sha256", secret).update(body).digest("hex");
assert(headerValue === `sha256=${expected}`);If TARGET_WEBHOOK_URL is empty, the relay logs the payload to stdout (dry-run mode) instead of sending it.
Each outbound Trade can optionally include FX rate information so downstream systems can convert cost into a single reporting currency. Opt in via .env:
FX_RATES_ENABLED=true
FX_RATES_BASE_CURRENCY=EUR
# Optional — enables historical rates for any trade date:
#FX_RATE_API_KEY=your-exchangerate-api-key
# Optional — retention for cached historical rates in the meta DB (default: 730):
#FX_CACHE_RETENTION_DAYS=730Convention: fxRate is expressed as units of fxRateBase per 1 unit of currency, so cost * fxRate = cost_in_base. Example: for a USD trade with base EUR, fxRate ≈ 0.835 (i.e. 1 USD → 0.835 EUR).
With an API key (exchangerate-api.com) — the relay uses the /history/{base}/{YYYY}/{M}/{D} endpoint to fetch the trade-day rate. Rates are cached in-memory and persisted to the relay-meta Docker volume so restarts don't refetch.
Without an API key — the relay falls back to the keyless open.er-api.com latest endpoint (no history available). Trades older than today ship with fxRate=null and a human-readable reason appended to the payload's errors array:
{
"relay": "ibkr",
"type": "trades",
"data": [{ "orderId": "123", "currency": "USD", "fxRate": null, "fxRateBase": null, "fxRateSource": null, ... }],
"errors": ["Trade 123: historical FX unavailable (trade date 2026-04-10 < today 2026-04-19; set FX_RATE_API_KEY to enable historical lookups) — fxRate omitted"]
}Currency detection is per-relay:
- IBKR — lifted directly from the Flex XML
currencyattribute / bridgecontract.currency. - Kraken — resolved from the pair's quote side. Known stablecoins are normalised (
USDT/USDC/DAI/PYUSD/TUSD/FDUSD/USDP→USD;EURT/EURC→EUR;GBPT→GBP). Crypto-quoted-in-crypto pairs (e.g.ETH/BTC) ship withoutfxRate.
Upstream failures, unknown currencies, and missing API keys are isolated per-trade: a single bad lookup never prevents a trade from shipping.
To test webhook delivery without hitting production services, set DEBUG_WEBHOOK_PATH in .env:
DEBUG_WEBHOOK_PATH=abcdef
#MAX_DEBUG_WEBHOOK_PAYLOADS=100
#DEBUG_LOG_LEVEL=INFOThis starts the debug container and reroutes all webhook delivery to an in-memory inbox at /debug/webhook/<path>. The real TARGET_WEBHOOK_URL is ignored while this is set.
Inspect captured payloads:
# View all stored payloads
curl -s https://trade.example.com/debug/webhook/abcdef | python3 -m json.tool
# Clear the inbox
curl -s -X DELETE https://trade.example.com/debug/webhook/abcdefStream payloads in real time — set DEBUG_LOG_LEVEL=DEBUG and tail the container logs:
make logs S=debug
# or: docker logs -f debugPayloads are logged at DEBUG level (full payload + headers). At INFO level (default), only a count summary is logged. Log rotation is aggressive (max-size: 10k) so sensitive data does not accumulate on disk.
To disable, remove or comment out DEBUG_WEBHOOK_PATH and run make sync. The container stops automatically (DEBUG_REPLICAS=0).
When a notifier fails to deliver (after retries exhaust), the relay can email the operator via Resend so a broken destination — quota-exhausted webhook, dead URL, malformed receiver — surfaces immediately instead of being buried in docker logs.
Alerting is opt-in. If RESEND_API_KEY or ALERT_REPORT_EMAIL_TO is unset, the alerter is a silent no-op. Enable it by setting both required vars in .env:
RESEND_API_KEY=re_xxxxxxxxxxxx
ALERT_REPORT_EMAIL_TO=ops@example.com
#ALERT_EMAIL_FROM=alerts@example.com # optional, defaults to onboarding@resend.dev
#ALERT_COOLDOWN_MINUTES=60 # optional, default 60What triggers an alert. Each notifier failure (per-backend, after retries exhaust) fires one email. Partial-success cycles still alert on the failing backend. The body includes:
- Notifier class, relay name, suffix, destination URL
- Attempt count and the underlying exception message — including the receiver's response body excerpt (e.g.
"You've exceeded your daily quota") - Timestamp and a CTA pointing the operator at logs
What does NOT go in the email. The trade payload itself is intentionally omitted — it can contain account IDs and execution data.
Throttling. The first failure for a given destination fires immediately. Subsequent failures within ALERT_COOLDOWN_MINUTES (default 60) are suppressed. State is in-memory: a container restart with a still-broken destination re-fires once, which is itself useful signal.
Best-effort guarantee. Alert delivery never raises, never affects the retry/mark-processed contract. A misconfigured ALERT_COOLDOWN_MINUTES, a Resend outage, or a network error all log a single line and are otherwise invisible to the relay.
Before deploying, create an Activity Flex Query in IBKR Client Portal:
- Log in to Client Portal
- Go to Reporting → Flex Queries
- Under Activity Flex Query, click + to create a new query
- Set Period to Last 7 Days (covers missed fills if the droplet was down)
- In Sections, enable Trades and select the execution fields you want
- Set Format to XML
- Set the Date Format to
yyyyMMdd, the Time Format toHHmmss, and the Date/Time Separator to;(semi-colon) — these are the only values the parser supports. Any other combination will cause fill rows to be skipped with a timestamp parse error. - Save and note the Query ID (use as
IBKR_FLEX_QUERY_IDin.env.relays) - Go to Flex Web Service Configuration → enable and get the Current Token (use as
IBKR_FLEX_TOKENin.env.relays)
The IBKR poller calls the Flex Web Service at the configured interval (default: 600s). Override with IBKR_POLL_INTERVAL in .env.relays.
Rate limit: IBKR enforces a limit of 10 requests per minute per token (and 1 per second), per Flex Web Service error code 1018. The limit is scoped to the token, so multiple query IDs (e.g.
_2suffixed pollers) share the same budget. Hitting it returnsErrorCode 1018 — Too many requests. The technical floor is ~6 seconds, but Flex report generation is slow (5–30 s typical), retries need headroom, and there's no benefit to polling faster than the broker generates data. We recommendIBKR_POLL_INTERVAL(orPOLL_INTERVAL) at 420 seconds (7 minutes) minimum; the default of 600 s (10 min) is a comfortable margin.
Why Activity instead of Trade Confirmation? Trade Confirmation queries are locked to "Today" only. Activity queries support a configurable lookback period, so if the droplet is offline for a few days the first poll after restart will catch all missed fills. The SQLite dedup prevents double-sending.
The IBKR relay includes an optional real-time listener that subscribes to ibkr_bridge's WebSocket event stream for near-instant fill delivery — complementing the Flex poller (which runs every 10 minutes by default).
Prerequisite: A running ibkr_bridge instance is required. The listener authenticates via the bridge's
API_TOKEN.
Add the following to .env:
LISTENER_ENABLED=trueAnd set the bridge connection vars in .env.relays:
IBKR_BRIDGE_WS_URL=ws://bridge:5000/ibkr/ws/events # container-to-container (same Docker network)
# IBKR_BRIDGE_WS_URL=wss://trade.example.com/ibkr/ws/events # cross-droplet (TLS)
IBKR_BRIDGE_API_TOKEN=your_bridge_api_token # must match bridge's API_TOKENThen run make sync to push the config and restart the relays container.
The listener processes two event types from the bridge stream:
| Event | Default | Description |
|---|---|---|
commissionReportEvent |
enabled | Fired after commission is confirmed — contains the final fill with fee data. This is the primary fill event. |
execDetailsEvent |
disabled | Fired immediately on execution — no commission data yet. Enable with IBKR_LISTENER_EXEC_EVENTS_ENABLED=true for sub-second latency at the cost of 2× webhook volume (one preliminary + one confirmed per fill). |
- Dedup is shared with the Flex poller. Both the listener and the Flex poller write to the same SQLite dedup database. A fill delivered by the listener will be silently skipped if the Flex poller later sees the same
execId, and vice versa. - Auto-reconnect with backoff. On disconnect or error the listener waits (starting at 5 s, up to 5 min) and reconnects automatically. The last seen sequence number is sent on reconnect so the bridge can replay any missed events.
- Debounce (optional). Set
LISTENER_DEBOUNCE_MS(milliseconds, default0) to buffer rapid partial fills before dispatching a single batched webhook. Useful when a large order fills in many small lots within a short window.
Remove or comment out LISTENER_ENABLED (or set it to false) and run make sync. The listener task is not started on the next container restart.
To add Kraken as a relay:
- Create API credentials at Kraken under Settings > API
- Required permissions: Query Funds, Query Open Orders & Trades, Query Closed Orders & Trades, Access WebSockets API
- Add to
.env:RELAYS=kraken # or RELAYS=ibkr,kraken for both
- Add to
.env.relays:KRAKEN_API_KEY=your_api_key KRAKEN_API_SECRET=your_base64_encoded_secret
- Run
make syncto push config and restart.
The Kraken poller calls the TradesHistory REST endpoint at the configured interval (default: 600s). Override with KRAKEN_POLL_INTERVAL in .env.relays.
Enable the WebSocket v2 listener for near-instant fill delivery:
# .env.relays
KRAKEN_LISTENER_ENABLED=trueThe listener connects to wss://ws-auth.kraken.com/v2, subscribes to the executions channel, and pushes fills to your webhook as they execute. No external bridge required — Kraken's native WS API is used directly.
Kraken stamps each execution event with the order's lifecycle state. When a fill arrives carrying order_status == "filled", the listener flushes that order's debounce buffer immediately instead of waiting for KRAKEN_LISTENER_DEBOUNCE_MS to elapse. The debounce window only matters when an order is still receiving partial fills — once the order is fully done, the trade webhook ships within milliseconds.
Kraken's WS v2 executions channel does not reliably include fees in real time — fee_usd_equiv is often 0 at the moment of the fill event regardless of whether the order matched a single counter-party or several, and Kraken does not document a settlement timing. The REST TradesHistory endpoint (used by the poller) returns the fee once the trade has settled.
Practical consequences when both KRAKEN_LISTENER_ENABLED=true and KRAKEN_POLLER_ENABLED=true:
- Listener fee is best-effort. Whether the listener's webhook carries a non-zero fee depends on Kraken's internal timing — assume it doesn't.
- Single-match fills (one client order matched against a single counter-party): the WS
exec_idequals the RESTtxid, so the poller's later attempt is silently deduped against the listener's exec_id. The consumer receives one webhook, from the listener, typically withfee=0. - Multi-match fills (one client order matched against several counter-parties): the WS emits one event per match while REST returns a single consolidated trade under a brand-new
txidthat does not match any listener exec_id. The order-level dedup suppresses this REST-side duplicate, so again the consumer receives one webhook, from the listener, typically withfee=0. - Poller-only (set
KRAKEN_LISTENER_ENABLED=false): the trade arrives withinKRAKEN_POLL_INTERVALof the fill, always with fees. SettingKRAKEN_POLL_INTERVAL=60(or similar) is a reasonable middle ground when fees are critical and ~1-minute latency is acceptable.
In short: enabling the listener trades fee accuracy for latency. If your consumer needs fees, run poller-only with a shorter KRAKEN_POLL_INTERVAL.
{
"relay": "kraken",
"type": "trades",
"data": [
{
"orderId": "OXXXXX-XXXXX-XXXXXX",
"symbol": "XETHZUSD",
"assetClass": "crypto",
"side": "buy",
"orderType": "limit",
"price": 2450.5,
"volume": 0.5,
"cost": 1225.25,
"fee": 0.32,
"fillCount": 1,
"execIds": ["TID-XXXXX-XXXXX"],
"timestamp": "2026-04-12T15:30:00Z",
"source": "rest_poll",
"raw": { "txid": "TID-XXXXX-XXXXX", "pair": "XETHZUSD", "...": "..." }
}
],
"errors": []
}Trigger an immediate poll without waiting for the next interval:
make pollAdditional flags:
make poll RELAY=ibkr IDX=2 # second account
make poll V=1 # verbose — stream container logs alongside poll
make poll REPLAY=3 # resend 3 trades even if already processed (for testing)
make poll REPLAY=5 V=1 # combine flagsOr use the CLI directly:
python3 -m cli poll ibkr 1 # normal (HTTP)
python3 -m cli poll ibkr 1 -v # verbose (stream logs)
python3 -m cli poll ibkr 1 --replay 3 # resend 3 tradesYou can also call the endpoint directly with curl:
source .env && curl -s -X POST "https://${SITE_DOMAIN}/relays/ibkr/poll/1" \
-H "Authorization: Bearer ${API_TOKEN}" \
| python3 -m json.toolThe relay uses a timestamp watermark per poller to skip fills already seen in previous cycles. In normal operation this is fully automatic. Use watermark-reset to fast-forward the watermark to the current time so the next poll skips any older backlog and starts fresh from new fills only. This is useful when you intentionally want to discard a backlog — for example, after a long outage when you only care about new activity going forward.
make watermark-reset # set watermark to now for all relays
make watermark-reset RELAY=ibkr # set watermark to now for ibkr only
make watermark-reset ENV=local # target the local Docker stackOr use the CLI directly:
python3 -m cli watermark-reset # all relays
python3 -m cli watermark-reset --relay ibkr # single relay
python3 -m cli watermark-reset --relay ibkr kraken # multiple relaysThe command resets watermark keys already present in the metadata DB for the given relay(s) to int(time.time()). If a relay has no watermark rows yet, it initializes the default poller watermark for that relay. Poller indices that have never written metadata yet (for example, a multi-account _2 poller before its first successful poll) are not discovered by this command until they have run at least once. After the reset, the next poll cycle will only process fills timestamped at or after that moment for the pollers whose watermark keys were reset.
Note: The dedup layer is not cleared by this command — fills already marked as processed are still skipped. To also clear dedup state use
make reset-db(drops both tables) ormake poll REPLAY=Nto resend the last N fills regardless of dedup state.
To stop billing for the droplet without losing state:
# Snapshot the droplet, unassign the reserved IP, delete the droplet
make pause
# Later — recreate the droplet from the snapshot and reassign the IP
make resumeCosts while paused:
- Droplet: $0 (deleted)
- Snapshot:
$0.06/GB/month ($0.05/month for a fresh 25GB disk) - Reserved IP: $5/month while unassigned (free when assigned to a droplet)
make sshStream relay logs in real-time (useful for checking fill deliveries):
make logs # droplet (default: relays)
make logs S=debug # debug inbox logsTargets the droplet by default. Set DEFAULT_CLI_ENV=local in .env.droplet (or pass ENV=local) to stream from the local stack instead:
make logs ENV=local # local relays
make logs S=debug ENV=local # local debug inbox- Firewall restricts SSH (22) to the deployer's IP only
- HTTP/HTTPS open (Caddy auto-redirects HTTP → HTTPS)
- Webhook payloads are HMAC-SHA256 signed
- No credentials stored in the repository
- Terraform infrastructure (droplet, firewall, SSH key)
- Docker Compose orchestration (3 containers)
- Multi-relay registry pattern (IBKR, Kraken)
- Flex poller with SQLite dedup + webhook delivery
- On-demand poll endpoint (
make poll/ HTTP API) - Deploy/destroy/pause/resume scripts
- Dry-run mode (log payloads when no webhook URL)
- Webhook endpoint (HMAC-SHA256 signed, batched payloads)
- Pluggable notification backends (currently: webhook)
- HTTPS via Caddy + Let's Encrypt
- Makefile CLI (
make deploy,make poll, etc.) - Unified Flex XML parsing (Activity + Trade Confirmation)
- TypeScript type definitions (
@tradegist/relayport-types, not yet published) - Python type definitions (
relayport-types, not yet published) - Multi-account support within each relay (
_2suffix) - Debug webhook inbox (
DEBUG_WEBHOOK_PATH) - Real-time listener (ibkr_bridge WebSocket)
- Env file split (
.env+.env.droplet+.env.relays) - Health monitoring / alerting
- Kraken crypto exchange adapter (REST poller + WS v2 listener)
- Additional broker adapters
Developer and contributor documentation — testing, full commands reference, project structure, type regeneration, and broker-adapter internals (including the Flex XML parser and IBKR ID reference) — lives in CONTRIBUTING.md.