Connect — integration guide
For an organization: onboard your own users, have them link venue accounts on the hosted page, and read or trade for them. Execution itself is documented on Placing orders.
Before you start
Console → your organizationThree things, all self-serve in the console under your organization:
- An organization key — your
X-API-Keyfor every server call. It carriesconnect:read,connect:writeandconnect:tradeon an organization plan. Shown once at mint. - A Connect flow — the venues offered, real money or practice-only, the permissions a user may grant, AI providers, branding, and your allowed return URLs.
- A webhook endpoint per environment, each with its own secret, if you want to be told when a user connects.
X-API-Key: om_exec_live_... every call (identifies your organization)
X-OpenMarkets-Account: your-user-123 scopes the call to one of your users
Base URL https://api.openmarkets.ai. Identity is (your organization, external_user_id) — never an email. Each organization's users are fully isolated: a call naming a user you do not own is 403 account_forbidden, whether or not the user exists.
How it works
- 01Create a link session· your backend
POST /flow/v1/connect/link-sessionsReturns a one-time hosted_url and link_token for this user.
↓ redirect the user, or open the button
- 02The user links their venues→ OpenMarkets · hosted
connect.openmarkets.aiCredentials are entered and validated here, never through you. They also set per-venue permissions and limits.
↓ user.partners_connected · signed webhook
- 03Verify and record· your backend
X-OpenMarkets-SignatureCheck the HMAC, dedupe on delivery_id, mark the user connected.
↓ then, any time
- 04Read and act· your backend
GET /flow/v1/auth/account/partners · POST /flow/v1/auth/orders/buyBalances, positions and history — or trade on their behalf, within what they granted.
1 · Mint a link
POST /flow/v1/connect/link-sessions
X-API-Key: om_exec_live_...
{ "external_user_id": "your-user-123",
"return_url": "https://app.yourco.com/openmarkets/done" }
→ { "hosted_url": "https://connect.openmarkets.ai/start?token=lt_live_...",
"link_token": "lt_live_..." }
Redirect the user to hosted_url. The first call also provisions the user. Add "mode": "manage" for the limits and accounts screen instead of the venue picker. The link is single-use and expires in 15 minutes.
To create the user up front — before they ever open a link, for example to hold a practice balance — provision them directly. It is idempotent on external_user_id, so it is safe to call on every login:
POST /flow/v1/connect/users
X-API-Key: om_exec_live_...
{ "external_user_id": "your-user-123", "email": "optional", "name": "optional" }
→ 201 when created, 200 when the user already existed
{ "om_account_id": "...", "external_user_id": "your-user-123", "created": true }
Prefer a popup, so the user never leaves your app? Use the button and hand it the link_token from the same response.
import { OpenMarketsConnectButton } from '@openmarketsai/connect-web/react'
<OpenMarketsConnectButton
variant="dark" // 'dark' | 'light' | 'outline'
getToken={() => fetch('/api/link-token').then(r => r.json()).then(d => d.link_token)}
onSuccess={({ connected_partners }) => refresh(connected_partners)}
onExit={({ reason }) => reason === 'error' && showRetry()}
/>
// No framework:
import { createConnectButton } from '@openmarketsai/connect-web'
createConnectButton(document.querySelector('#connect'), { variant: 'dark', getToken })
getToken runs on click, not on mount — link tokens are single-use and expire in 15 minutes, so minting one on page load is a reliable way to hand users a dead link. Omit variant for an unstyled button you style yourself.
2 · Webhooks
POST {your endpoint} · X-OpenMarkets-Signature: t=1758500000,v1=<hex hmac>
{ "event": "user.partners_connected",
"delivery_id": "evt_...",
"external_user_id": "your-user-123",
"om_account_id": "...",
"connected_partners": ["kalshi"] }
Three events: user.partners_connected, user.permissions_changed, user.history_synced. Each delivery carries event, delivery_id, external_user_id, om_account_id and the event's own fields.
Verify the signature. The header is t=<unix seconds>,v1=<hex>; v1 is HMAC-SHA256 over `${t}.${rawBody}` with your endpoint's secret. Reject a t more than five minutes from now; retries are re-signed with a fresh timestamp. Compare in constant time. Then dedupe on delivery_id. Trust the webhook, not the redirect.
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify(rawBody: string, header: string, secret: string) {
const { t, v1 } = Object.fromEntries(header.split(',').map((kv) => kv.split('=')))
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
return expected.length === v1.length && timingSafeEqual(Buffer.from(expected), Buffer.from(v1))
}
You can register several endpoints (production, staging, an internal consumer), each on its own environment. Every endpoint receives every event for its environment, each signed with that endpoint's secret and retried on its own schedule — a staging box being down never delays production. All copies share one delivery_id. Endpoints, secrets, test events, the delivery log and redelivery are in the console under your organization's Connect settings; @openmarketsai/connect-node ships webhooks.verify and constructEvent.
3 · Read accounts
GET /flow/v1/auth/account/partners
X-API-Key: om_exec_live_... · X-OpenMarkets-Account: your-user-123
Connected venues and balances; a venue the user did not grant balance:read for is omitted rather than erroring. The same header works on /auth/account/balances, /auth/account/positions, /auth/orders and the AI pass-through. Shapes are on Venues & balances.
4 · Place a trade
Your user must have granted trade:execute for that venue. They choose this on the hosted page; without it the leg is refused with 403 scope_not_granted naming the venue. Your organization's connect:trade entitlement lets you act for a user — it does not override what that user agreed to, nor their execution limits. Send them back through "mode": "manage" to change either.
POST /flow/v1/auth/orders/buy
X-API-Key: om_exec_live_... · X-OpenMarkets-Account: your-user-123
{ "orders": [
{ "liquidity_hash": "<a venue's price from partner_liquidities[]>",
"amount": 50, // stake in USD — or "shares": 100
"max_price": 0.42 } // won't fill above this
]}
Name the venue and a price ceiling — pass a liquidity_hash from the liquidity feed (or a position_hash plus partner_id). Add "mode": "paper" for a practice fill. Strict — any other field is 400 unknown_parameter. A control-blocked leg (a venue the user paused, a stake over their cap) comes back 200 with venue_disabled or execution_limit_exceeded in results[i].rejected_code. Placing orders is the source of truth for multi-leg buys, resting orders, cancel and settlement.
Managing your users
connect:read · connect:writeEverything below is read-only over what the user set on the hosted page, except the sync trigger.
/flow/v1/connect/usersYour users, by external_user_id, with their om_account_id. A test key lists your test users; a live key, your real ones. GET /flow/v1/connect/users/:external_user_id returns one.
/flow/v1/connect/users/:external_user_id/policyThe user's execution policy as they set it: limits (an array of { type, params } rules such as a per-order cap or a venue allowlist) and venues (each with status and credential_status). 404 user_not_found if not provisioned. There is no write route — only the user changes this.
{
"external_user_id": "your-user-123",
"om_account_id": "...",
"limits": [ { "type": "max_stake_per_order", "params": { "amount": 250 } } ],
"venues": [ { "partner_id": "p_kalshi", "status": "active", "credential_status": "connected" } ]
}
/flow/v1/connect/users/:external_user_id/historyThe user's synced venue history, newest first — only from connections where they granted history:read. Query partner_id, limit (default 100, max 500) and offset. Each record carries the venue's own reference, kind, side, instrument_label, quantity, price, status, cost, payout, outcome, position_key, settled_pnl, occurred_at, and placed_via_openmarkets so you can tell your orders from theirs.
/flow/v1/connect/users/:external_user_id/history/syncAsk for a fresh sync from every venue that supports history. Needs connect:write. Returns { external_user_id, om_account_id, dispatched_partners }; the user.history_synced webhook fires when it lands. Syncs also run on a schedule and whenever the user opens the hosted page.
/flow/v1/connect/webhook-configThe organization's legacy single webhook: return_urls, webhook_url and whether a secret is set. POST (connect:write) updates any of them and mints a secret when rotate_secret is true or none exists — the plaintext is returned once. Per-environment endpoints with their own secrets, the delivery log and redelivery live in the console; prefer those for anything new.
{ "return_urls": ["https://app.yourco.com/openmarkets/done"],
"webhook_url": "https://app.yourco.com/hooks/openmarkets",
"webhook_secret_set": true }
Environments
| With a test key | With a live key |
|---|---|
| a sandbox session offering one credential-free venue, OpenMarkets Practice | the venues your flow allows |
| your test users, a separate set under the same ids | your real users |
| test webhook endpoints and their secrets | live endpoints and theirs |
Nothing migrates at launch: mint a live key and change the credential. See Test mode.
@openmarketsai/connect-node wraps every route on this page; @openmarketsai/connect-web is the drop-in launcher.