Sign in with OpenMarkets
One button. The user approves exactly what your app may do, and you get a scoped key that refreshes itself. Ship a product on prediction-market data without building accounts, billing or venue integrations — you never handle a password or a card.
What you skip
The hard part of shipping is rarely the product — it's everything around it. Sign-up flows, password resets, payment processing, refunds, chasing people who forgot which email they used. Then, for this domain specifically: an integration per venue, and the liability of holding someone else's trading credentials.
Build on OpenMarkets and none of that is yours. Your users sign in with an OpenMarkets account, connect their own venue accounts through a hosted flow you never see, and you receive an API key scoped to exactly what they approved.
- Identity — accounts, sign-in and verification, already running
- Billing — if you charge, we charge them and pay you your share
- The data — every venue normalised into one model, one schema
- Venue connections — their Kalshi or Polymarket account, without you ever touching a credential
Add sign-in — about ten lines
Install the SDK. It has zero runtime dependencies, no install scripts, and ships its own types.
npm install @openmarketsai/app-sdk
import { createOpenMarketsAuth } from '@openmarketsai/app-sdk'
const auth = createOpenMarketsAuth({
clientId: 'your-app-slug',
redirectUri: window.location.origin + '/',
})
// Behind your "Connect OpenMarkets" button.
// Opens a popup and resolves with the outcome.
const result = await auth.signIn()
if (result.status === 'success') start(result.session)
if (result.status === 'denied') { /* they changed their mind — not an error */ }
if (result.status === 'error') showError(result.message)
// Call once on every page load too, so a redirect-mode return is handled.
await auth.handleCallback()
Then read data with the key. Use getApiKey() rather thansession.apiKey — access keys are short-lived and this renews them silently.
fetch('https://api.openmarkets.ai/flow/v1/markets', {
headers: { 'X-API-Key': (await auth.getApiKey())! },
})
What your user sees
app.openmarkets.ai/authorizeYour app's sign-in, not ours. The screen opens with your icon and name, says “Greenbook uses OpenMarkets for your account”, and walks through everything on that one card. No developer console, no API keys, no “get started” — someone signing up through your app knows they are getting an OpenMarkets account to use your app, and that is all they are asked to understand.
- Email. One field. New to OpenMarkets is fine — the account is created on the spot, and the email they get is titled “Your code to sign in to Greenbook with OpenMarkets”.
- Code. The six-digit code from that email, typed into the same window; or its link, which signs them in from another tab and tells them to come back — your window picks the session up by itself.
- Name. Asked once, for a new account, and skippable.
- What your app can do. Your requested scopes in plain words (identity shown with the actual values), optional ones with a checkbox, and a note if their account doesn't include something you asked for.
- Allow / Not now. Both return to your app — a cancel arrives as
denied, never as an error, and never strands them on a dashboard. One exception: if theredirect_uriyou sent is not one you registered, a cancel has nowhere safe to go — the popup closes itself (the SDK still reportsdenied), and in redirect mode the person sees “Nothing was shared” and closes the tab.
Two things happen without a click:
- A returning user who already approved your app is sent straight back with the grant they gave before — the same optional scopes declined. If you now require something new, they see the consent screen again. A new optional scope is not granted silently: ask for it with
prompt: 'consent'(SDK 0.4). On 0.2 or 0.3 you cannot ask — instead, when you edit your app after someone last answered consent and it now offers an optional scope they do not hold, they are shown consent once more on their next sign-in, once. Upgrade to ask deliberately. - Someone already signed in to OpenMarkets skips the email step and sees “Continue as Ada · Not you?” — switching accounts happens inside the flow.
await auth.signIn({ prompt: 'consent' }) // show consent even to a returning user (after you add a scope)
await auth.signIn({ loginHint: 'ada@example.com' }) // pre-fill the email field
Afterwards, at app.openmarkets.ai, a person who signed up through your app sees their account — the apps they connected (with revoke), their venue accounts, their AI keys — not the developer console. Your app's name and icon are shown there as where they signed up, so keep both current.
@openmarketsai/app-sdk 0.4 and optional; signIn() with no arguments is unchanged, and an app on 0.3 gets the new screen with no change at all.Nothing throws
Both signIn() and handleCallback() return a result rather than throwing, because a user changing their mind is a normal outcome — not an exception.
none— this page load carries no sign-in response; ignore itsuccess—result.sessionis yoursdenied— they cancelled, or closed the window. Not an error.error— something actually failed;result.codesays what
denied explicitly. Showing an error message to someone who simply decided not to connect is the most common way this flow feels broken when it isn't.Let a user link venues without leaving your app
OM-02 · account:linkAdd the account:link scope and your app can open OpenMarkets' hosted linking page for the person who signed in (this is Connect, through your app). They enter venue credentials on OpenMarkets' page — co-branded with your app's name and icon, with the OpenMarkets mark always shown — and come back to you. Your app never sees or sets a credential.
const res = await fetch('https://api.openmarkets.ai/flow/v1/auth/account/link-sessions', {
method: 'POST',
headers: { 'X-API-Key': (await auth.getApiKey())!, 'Content-Type': 'application/json' },
body: JSON.stringify({ return_url: 'https://yourapp.example/settings?linked=1', mode: 'connect' }),
})
const { data } = await res.json()
window.location.assign(data.hosted_url) // 15-minute link; redirect, don't open a popup
return_urlmust be on the origin of one of your registered redirect URIs.mode:connectto add a venue,manage(the default) to review what's linked.- It's a redirect flow, so it works from a native app through the system browser too.
- Linked venues belong to the user's own account; your app trades on them only if it also holds
trade:execute.
Notify your users
OM-10 · notify:send · API live, SDK soonWith the notify:send scope, your server can ask OpenMarkets to email the people who installed your app. You send text; OpenMarkets renders it in its own template, sends it as “Your App via OpenMarkets”, and adds a one-click unsubscribe for your app. You never handle an email address.
await fetch('https://api.openmarkets.ai/flow/v1/apps/notifications', {
method: 'POST',
headers: { 'X-API-Key': key, 'Content-Type': 'application/json' },
body: JSON.stringify({
recipients: followerUserIds, // userinfo.user_id values — never emails
title: 'Fresh pick from @sam', // one line, ≤ 120 chars (the subject)
body: 'Lions -3.5 at -110 · 2 units', // plain text, ≤ 1000 chars
cta: { label: 'Tail it', url: 'https://yourapp.example/p/123' },
idempotency_key: 'pick:123', // reuse it to retry safely
}),
})
// 202 { app_notification_id, queued, skipped: [{ user_id, reason }] }
- Only people who installed your app and granted
notify:sendreceive it; others come back inskippedwith a reason (skipped_not_consented,skipped_opted_out, …). - The call-to-action URL must be
httpson one of your registered redirect origins. - Up to 500 recipients per request, and at most 10 emails an hour from your app to any one person.
- Sending is asynchronous — the response means queued, not delivered.
Show a verified betting record
OM-03 · record:readrecord:read lets a user share their settled record from the venues they've linked — without handing your app their account. It is deliberately narrower than account:read: no balances, no open positions, no individual bets.
GET /flow/v1/auth/account/record
→ { venues: [{ partner_name: 'Kalshi', settled: 212, won: 119, lost: 90, push: 3, void: 0,
staked: 4180.5, returned: 4466.2, net: 285.7, roi_pct: 6.8,
first_settled_at, last_settled_at }],
connections: [{ partner_name: 'Kalshi', supports_history: true, last_synced_at },
{ partner_name: 'BettorEdge', supports_history: false, last_synced_at: null }],
totals: { … }, as_of }
- Numbers come from OpenMarkets' synced venue history — the user can't edit them.
connectionslists every linked venue, including ones with no readable history, so a record built from only some venues is visible. Show it.- Only the account owner can share it; it can't be read on behalf of a Connect user.
- A stale record triggers a background sync; show
last_synced_at.
Check what you were granted
A user's plan may not cover everything your app asks for, and the user may untick any optional scope on the consent screen — capabilities as well as identity. A required scope the plan does not include blocks the install: the consent screen tells the user their plan doesn't include it. An optional scope the plan lacks, or one the user unticks, does not: the install succeeds and the scope is simply absent, so gate features on what you actually received rather than on what you requested. Make a scope optional if your app can work without it.
To offer a feature the user declined, send them through sign-in again: re-authorizing replaces the install's grant with what they allow this time. An app whose grant holds no execution scope (trade:execute, account:write) is issued a data-tier key.
trade:execute covers POST /auth/orders/buy, creating a model and a practice deposit; reading the result — GET /auth/orders, listing /auth/models, the practice wallet ledger — needs account:read. Request both if your app places orders.if (auth.can('trade:execute')) enableOrderEntry()
if (auth.can('depth:read')) showFullOrderbook()
Know who signed in
Ask for identity the same way you ask for anything else: add profile:read (name and username) and/or email:read (email address) to your app's scopes, as required or optional. Identity is never limited by the user's plan — a free account can sign in to an app that needs an email.
The consent screen shows the user the actual values you will receive, before they click Allow. A required item is listed; an optional one has a checkbox, and a user who unticks it installs your app without sharing it. So treat an optional scope as optional.
const user = auth.getUser()
// { userId: 'usr_…', name: 'Ada Lovelace', username: 'ada',
// email: 'ada@example.com', emailVerified: true } — or null
- Key your users on
userId(user_idon the wire). It is stable for this person in your app — across sign-ins, reinstalls and email changes — and different in every other app. Never key on email. - Each field is present only when its scope was shared. Without any identity scope there is no
userat all. - A server holding the key can read the same object, fresh, from
GET /flow/v1/userinfo(403scope_not_grantedwhen nothing was shared). It also comes back on every refresh, so an email change shows up within a key's life.
Popup or redirect
Sign-in opens a popup by default, which keeps your app alive underneath. A full redirect destroys in-memory state — for a trading interface that means a half-composed order and a dropped live feed. A blocked popup falls back to a redirect automatically, because a blocked popup is invisible: the user clicks and nothing happens.
createOpenMarketsAuth({ /* … */ mode: 'redirect' })
You write no window coordination. handleCallback() detects when it's running inside the sign-in popup, reports the outcome to the opener and closes itself. Both windows run your app, so the single call covers both roles.
Keys expire, and refresh tokens rotate
An access key lasts 7 days; the refresh token that renews it lasts 90 days.getApiKey() renews silently, so users never see either number. An app left unused for 90 days asks for consent again.
getApiKey(), which handles concurrency for you. Never call refresh() yourself from a cached copy of the session.Apps with a server
The SDK keeps the session in the browser, which is right for an app that only works while the user has it open. If your app acts while they are away — a scheduler, a bot, anything running on a timer — the server has to hold the credential, so run the same flow yourself. It is plain OAuth 2.0 authorization code with PKCE (S256).
1. Send the user to consent. Generate a random code_verifier and state, keep both server-side against the user's session, and redirect to:
https://app.openmarkets.ai/authorize
?client_id=your-app-slug
&redirect_uri=https://yourapp.com/auth/callback # registered exactly
&state=<random>
&code_challenge=<base64url(sha256(code_verifier))>
&code_challenge_method=S256
2. Handle the callback. It arrives with code and state, or error=access_denied if the user declined — which is not a failure. Compare state to the one you stored before spending the code, then exchange it:
curl -X POST https://api.openmarkets.ai/flow/v1/apps/token \
-H 'Content-Type: application/json' \
-d '{ "app_id": "your-app-slug", "code": "…", "code_verifier": "…",
"redirect_uri": "https://yourapp.com/auth/callback" }'
# 201 → data:
{
"api_key": "om_exec_live_…", // shown once — send as X-API-Key
"expires_at": "2026-09-28T…", // 7 days
"refresh_token": "…", // shown once
"refresh_expires_at": "2026-12-20T…", // 90 days
"key_type": "execution",
"scopes": ["markets:read", "trade:execute", "email:read"],
"workspace_id": "wsp_…",
"app_install_id": "…",
"user": { // only when an identity scope was shared
"user_id": "usr_…",
"email": "ada@example.com",
"email_verified": true
}
}
3. Refresh before the key expires with { "grant_type": "refresh_token", "app_id": "your-app-slug", "refresh_token": "…" } to the same endpoint. The response has the same shape: a new key and a new refresh token, and the old refresh token is dead the moment it is used.
SELECT … FOR UPDATE) on the user's credential, re-read it inside the lock, refresh, and commit the new refresh token before releasing it. Never refresh from a cached copy.- Store both tokens encrypted. An execution key can place orders; treat it like a password.
- Key the user on
user.user_idwhen you requested identity (see above), otherwise onworkspace_id. Both are stable across refreshes and re-consents. - A refresh that fails is final. It answers
400 invalid_grant, anderror.details.reasonsays why so you can tell the user:expired(unused for 90 days),revoked(they uninstalled),reused(a token was presented twice — fix your lock), orplan_no_longer_covers(their plan dropped a scope you require). A token we don't recognise gets no reason. Whatever the reason, do not retry — mark them disconnected, tell them, and send them back through step 1. - Scopes can narrow on refresh, if the user's plan changed. Read
scopesfrom every response rather than remembering the first.
Getting listed
OM-11 · app.openmarkets.aiRegister your app in the console under Apps — publishing is open to every developer. Once listed it appears in the marketplace, the place users manage what has access to their account. Every app is reviewed by a person before it lists, read-only ones included; anything with a write or execute scope is looked at more closely. That isn't bureaucracy: your app's name and your publisher name are shown on the consent screen, and a user has to be able to trust what they read there.
- Pick a slug. It's your public identifier and your
clientId. It cannot change once people have installed. - Register your redirect URIs. Matched exactly — an authorization code is a credential, so a prefix rule would be unsafe. Register every environment you use.
- Request the narrowest scopes that work. Required scopes block an install when a plan doesn't cover them; optional ones let the app install and degrade — and the user can decline them. Make anything a feature can live without optional.
- Install it yourself, then invite testers. A draft app is installable by its publisher and up to ten invited testers, in the test environment, before anyone else can see it.
- Submit for review. Anything that can trade or write is looked at more closely.
http://localhost redirect URIs are accepted so you can build the integration before you have a domain. Production URIs must be https.Getting paid
Apps can be free or paid. On a paid app we charge the user, and you keep 75% of what they pay — our share covers the billing, the identity layer and the market data your app runs on.
You implement no billing at all. There is no licence check to write, because the enforcement isn't in your code — a user who hasn't paid never completes an install, so your app never receives a key.
Start here
Mint a key and build against the data first — you don't need a registered app to start, and nothing here is gated behind talking to us. When you have something working and want your users to sign in with it, register the app in the console.