Sign in with OpenMarkets

@openmarketsai/app-sdk · client_id = slug · POST /flow/v1/apps/token · GET /flow/v1/userinfo
Live

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
To be straight about what this is not: listing here is not a distribution channel. We are not going to hand you an audience — bring your own users, or your own idea of who they are. What we remove is the six weeks of plumbing between that idea and a working product.

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/authorize

Your 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 the redirect_uri you sent is not one you registered, a cancel has nowhere safe to go — the popup closes itself (the SDK still reports denied), 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.

Both options are new in @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 it
  • success — result.session is yours
  • denied — they cancelled, or closed the window. Not an error.
  • error — something actually failed; result.code says what
Handle 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.

Notify your users

OM-10 · notify:send · API live, SDK soon

With 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:send receive it; others come back in skipped with a reason (skipped_not_consented, skipped_opted_out, …).
  • The call-to-action URL must be https on 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:read

record: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.
  • connections lists 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.

Placing an order you can read back takes two scopes. 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_id on 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 user at all.
  • A server holding the key can read the same object, fresh, from GET /flow/v1/userinfo (403 scope_not_granted when 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.

Refresh tokens rotate: each refresh retires the previous one. Presenting a retired token is treated as a leak and revokes the entire install — every key, every token — because the legitimate holder would already have rotated it. Always go through 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.

Refresh under a lock, and save before you use. Presenting a refresh token that was already rotated is treated as a leak and revokes the whole install. So two workers refreshing the same user at once will sign that user out. Take a row lock (for example 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_id when you requested identity (see above), otherwise on workspace_id. Both are stable across refreshes and re-consents.
  • A refresh that fails is final. It answers 400 invalid_grant, and error.details.reason says why so you can tell the user: expired (unused for 90 days), revoked (they uninstalled), reused (a token was presented twice — fix your lock), or plan_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 scopes from every response rather than remembering the first.

Getting listed

OM-11 · app.openmarkets.ai

Register 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.