InterposeInvest

Sandbox environment. Interpose is not a registered broker-dealer and holds no FINRA approval. Every API documented here is available in a simulated evaluation environment only — no client assets are held, no real trades are executed, and no custody is provided. The calculation engines are the production code paths; fills, custody, settlement and KYC decisioning are simulated. What is real and what is simulated →

React to what happens, instead of polling for it.

Interpose is event-sourced: every fill, position change and rebalance is published as an immutable event, and positions are derived by folding those events. Your systems can subscribe to that stream rather than asking for changes on a timer — which is the difference between noticing a fill at 09:00 tomorrow and noticing it at 14:23:07 today.

Two transports are reachable from your own infrastructure. Server-Sent Events is ordinary HTTP and needs nothing but a bearer token. WebSocket costs one extra call — you mint a single-use ticket first — and gives you a socket a browser can open too. For higher volume, Kafka is available by arrangement. The ledger behind all three is durable and append-only; the stream is a live view of it, not a replay log.

Transport availability

TransportStatusNote
Server-Sent EventsLiveOrdinary HTTP — no upgrade, no ticket. Scoped to your book
WebSocketLiveBaaS and Portfolio Management, via a single-use ticket. Every subscription is authorized
KafkaBy arrangementSelf-hosted broker; connection details arranged during onboarding. No self-serve path
WebhooksRegistration onlyYou get a subscription and a signing secret. Interpose dispatches nothing to it today
SQS fan-outNot availableThe AWS dependency is in no service image

Server-Sent Events

Seven streams are registered under BaaS, all reachable at the normal public base URL. They are ordinary HTTP — no upgrade, no ticket, no special client — which makes them the shortest path to a working integration, and the transport that survives a corporate proxy refusing to upgrade a connection. The four below are the ones to build on:

GET/api/v1/baas/events/trades
GET/api/v1/baas/events/positions
GET/api/v1/baas/events/activities
GET/api/v1/baas/events/account/status

Three more are registered and will connect. /events/funding/status and /events/journals/status have emitters behind them and carry real traffic; /events/system has no emitter at all, so it connects and stays empty.

curl — the -N matters
curl -N -H "Authorization: Bearer $TOKEN" \
     -H "Accept: text/event-stream" \
     https://interposehq.com/api/v1/baas/events/trades

The response is text/event-stream; the connection stays open and each event arrives as a data: line carrying the CloudEvents envelope. Use -N with curl, or disable buffering in your client, or you will see nothing until the buffer flushes.

Python
import httpx, json

with httpx.stream("GET", "https://interposehq.com/api/v1/baas/events/trades",
                  headers={"Authorization": f"Bearer {token}",
                           "Accept": "text/event-stream"},
                  timeout=None) as r:
    for line in r.iter_lines():
        if line.startswith("data:"):
            event = json.loads(line[5:])
            print(event["type"], event["id"])

The stream is scoped to your book

Every event is filtered against the same authorization predicate that guards the REST route for the account it belongs to, so a connection carries your firm's events and no one else's. The account_id query parameter narrows it further and now matches — it reads data.account_id off the envelope, which is where the platform actually puts it. Naming an account you are not entitled to returns 403 rather than an empty stream.

one account
curl -N -H "Authorization: Bearer $TOKEN" \
     -H "Accept: text/event-stream" \
     'https://interposehq.com/api/v1/baas/events/trades?account_id=acct_01HV4Y...'

Assume at-least-once delivery

Dedupe on the CloudEvents id — a reconnect can replay. An event whose account cannot be resolved is withheld rather than broadcast, which is the second reason /events/system stays empty for you; see Current limits.

WebSocket

The WebSocket streams are reachable at the public base URL. A handshake carries no Authorization header — the browser WebSocket constructor takes a URL and a subprotocol list and nothing else — so you exchange your ordinary credential for a single-use ticket over REST first, then connect with that.

PillarMint a ticketConnect
BaaSPOST /api/v1/baas/stream/ticketswss://interposehq.com/api/v1/baas/stream
Portfolio ManagementPOST /api/v1/pm/stream/ticketswss://interposehq.com/api/v1/pm/pm/stream

The doubled pm is real — copy it exactly

Portfolio Management declares its socket at /v1/pm/stream on the service, and the proxy rewrites /api/v1/pm/(.*) to /v1/$1, so the public URL carries the segment twice. Its ticket endpoint is declared at /v1/stream/tickets and therefore carries it once. Two paths on one pillar, one doubled and one not; both are correct and neither is a typo.

1 — mint a ticket over ordinary REST
curl -X POST https://interposehq.com/api/v1/baas/stream/tickets \
  -H "Authorization: Bearer $TOKEN"

{"ticket": "eyJhbGciOiJIUzI1NiIs...", "token_type": "ws_ticket", "expires_in": 60}
2 and 3 — connect, then subscribe
import asyncio, json, httpx, websockets

BASE = "https://interposehq.com/api/v1/baas"

ticket = httpx.post(f"{BASE}/stream/tickets",
                    headers={"Authorization": f"Bearer {token}"}).json()["ticket"]

async def main():
    async with websockets.connect(f"wss://interposehq.com/api/v1/baas/stream?ticket={ticket}") as ws:
        await ws.send(json.dumps({"action":     "subscribe",
                                  "account_id": "acct_01HV4Y2K3M5N8P9QR2ST4UVWXY"}))
        print(json.loads(await ws.recv()))
        # {"type": "subscribed", "account_id": "acct_01HV4Y2K3M5N8P9QR2ST4UVWXY"}

        async for message in ws:
            frame = json.loads(message)
            print(frame["type"], frame.get("data"))

asyncio.run(main())

A socket frame is not a CloudEvent

Kafka and Server-Sent Events carry the CloudEvents envelope below. The socket sends its own shape — a type and the fields that go with it, no specversion, id or time. Expect position.updated, account.buying_power.updated, margin.call.issued and the PDT counters on your subscribed account, and note that price_update and fx_rate_update ticks go to every open socket rather than to a subscription — they are market data, not account data. There is no id to dedupe on here, so use SSE or Kafka where you need one.

Ticket propertyValue
Lifetime60 seconds — mint it immediately before you connect, not at start-up
ReuseSingle use. A reconnect needs a fresh ticket
Credential to mint itA bearer token or an HMAC-signed request — whichever you already use for REST
EntitlementsCopied from your claims at mint time, so the socket authorizes exactly as REST would
A portal JWT in the URLRefused. The socket accepts tickets and nothing else

The reason for the extra call rather than ?token=<your portal JWT>: the application server logs the handshake path including its query string, and a portal token is valid for eight hours — so a logged URL would be a session handover. A spent 60-second ticket in the same log line is worth nothing.

One round trip, and from a browser

A browser cannot set a header on a handshake but can offer subprotocols, so the ticket may travel there instead. That form keeps it out of the access log altogether.

JavaScript
const { ticket } = await (await fetch("/api/v1/baas/stream/tickets", {
  method:  "POST",
  headers: { Authorization: `Bearer ${token}` },
})).json();

const ws = new WebSocket("wss://interposehq.com/api/v1/baas/stream",
                         ["interpose.v1", `interpose.token.${ticket}`]);

ws.onopen = () => ws.send(JSON.stringify({
  action: "subscribe", account_id: "acct_01HV4Y2K3M5N8P9QR2ST4UVWXY",
}));

Subscribing, and what a refusal looks like

Send {"action": "subscribe", "account_id": "acct_..."} within ten seconds of connecting and the server answers {"type": "subscribed", "account_id": "acct_..."}. Every subscribe frame is authorized against the same predicate as the REST route for that account, so a socket can only ever carry a book your credential could already read. One account per connection — open a second socket for a second account.

OutcomeWhat you see
Ticket missing, expired, already spent, or an ordinary JWTHTTP 403 on the upgrade — no close frame, no close code
Subscribed to an account you may not seeClose code 4403
Malformed subscribe frame, or none within ten secondsClose code 4002

A rejected handshake has no close code, and there is no 4401

Closing a socket before it is accepted cannot produce a close frame — the server turns a pre-accept close into a bare 403 and discards the code — so a client waiting on a close event to learn its credential was bad waits forever. Handle the failed upgrade. Close codes exist only after a successful accept, which is where 4403 and 4002 live.

The CloudEvents envelope

Server-Sent Events and Kafka both carry CloudEvents 1.0 — the same envelope, byte for byte, whichever you consume. The two senders that use their own shape are the WebSocket frame above and the webhook delivery below, and each says so where it is documented.

{
  "specversion": "1.0",
  "type":        "com.interpose.baas.order.filled",
  "source":      "com.interpose.baas",
  "id":          "evt_01HV4Y2K3M5N8P9QR2ST4UVWXY",
  "time":        "2026-05-11T14:23:00.000000000Z",
  "datacontenttype": "application/json",
  "issandbox":   true,
  "data": {
    "order_id":        "ord_01HV4Y...",
    "account_id":      "acct_01HV4Y...",
    "security":        { "type": "ticker", "value": "AAPL" },
    "side":            "BUY",
    "filled_quantity": "10.00000000",
    "avg_fill_price":  "182.4500",
    "currency":        "USD"
  }
}
FieldNotes
typecom.interpose.{pillar}.{resource}.{verb}
sourceThe emitting pillar's reverse-DNS identifier — com.interpose.baas. Not a URI path; routing on a /services/ prefix matches nothing.
idYour dedupe key.
issandboxA boolean extension attribute on every envelope. One word, all lowercase, as the CloudEvents spec requires of extension names.

There is no com.interpose.crypto.* namespace — crypto events are emitted under their owning pillar like everything else. New attributes are added inside the envelope without a version bump, so ignore unknown fields. Order holds within a partition; it does not hold across topics.

Event types you can rely on

com.interpose.baas.order.submittedcom.interpose.baas.order.filledcom.interpose.baas.position.updatedcom.interpose.pm.rebalancing.orders.created

Consuming Kafka directly

For higher-volume integrations, direct topic consumption avoids delivery overhead entirely. The broker is self-hosted; connection details and credentials are arranged during onboarding, and there is no self-serve path. Use your own consumer group id, start from latest unless you have a reason to replay, and expect the CloudEvents envelope above as the JSON payload.

TopicFires when
vm.baas.orders.submittedAn order is accepted and routed
vm.baas.orders.filledAn order fills, whole or partial
vm.baas.orders.cancelledAn order is cancelled
vm.baas.positions.updatedA position changes
vm.pm.rebalancing.initiatedA rebalancing run starts

Two namespaces, both correct

Topic names use the vm. prefix for historical reasons — it predates the Interpose name and is retained because renaming a topic is a migration, not a rename. The CloudEvents type field uses com.interpose.*. These are different namespaces, and both are right.

Webhooks

Webhook registration works today and event dispatch does not — the detail is in Current limits. The signature scheme below is documented because it is what you will verify against once dispatch ships, and because the one signed sender that is live — the professional-network subscription feed — already uses it.

HeaderValue
X-Interpose-SignatureHex HMAC-SHA256 over the canonical string
X-Interpose-TimestampSend time, unix epoch milliseconds
canonical string — a literal dot between the timestamp and the raw body bytes
{timestamp}.{raw_request_body}
Python
import hashlib, hmac, time, json

MAX_AGE_SECONDS = 300

def verify(raw: bytes, ts: str, sig: str, secret: str) -> dict:
    expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig):
        raise ValueError("bad signature")
    if abs(time.time() - float(ts) / 1000) > MAX_AGE_SECONDS:   # ts is in MILLISECONDS
        raise ValueError("stale delivery")
    return json.loads(raw)

The millisecond unit is the detail that bites

A freshness check written against time.time() in seconds rejects every genuine delivery as roughly 57 years stale. The canonical string uses the raw header value — divide only in the age check, never before signing.

Three further rules, each of which has burned somebody: verify the raw body before parsing, because re-serialising JSON changes the bytes; reject deliveries older than five minutes, because a valid signature on a replayed message is still a replay; and use constant-time comparison, because == on a signature is a timing oracle.

The delivery body is not a CloudEvent

Kafka and SSE carry the CloudEvents envelope. The webhook sender posts its own shape:

{
  "delivery_id": "dlv_01HV4Y2K3M5N8P9QR2ST4UVWXY",
  "event_type":  "consent.granted",
  "severity":    "INFO",
  "payload":     { },
  "occurred_at": "2026-05-11T14:23:00.000000000Z"
}

Dedupe on delivery_id. There is no id, specversion or type field to key on — those belong to the CloudEvents envelope, not to this one. Return 2xx quickly: acknowledge, enqueue, and do the work asynchronously.

Current limits

what is not built yet

Server-Sent Events, WebSocket and Kafka carry real traffic today. These are the parts of the event surface that do not yet, stated so you can design around them rather than discover them.

  • WebSocket covers BaaS and Portfolio Management

    Those are the two streams we publish public paths for, and a connection carries one account. Server-Sent Events is the broader surface today — seven streams, no per-socket subscription. If your integration needs a WebSocket on another pillar, ask us before you design around it.

  • Webhooks: registration works, dispatch does not

    You can register an endpoint and receive a one-time signing secret, but BaaS does not dispatch order, settlement or rebalance events to it today. You may find material describing a retry schedule of “3 attempts: 5s, 30s, 300s” — that describes code that was never written.

  • An event with no account on it is withheld, not broadcast

    Scoping resolves each event to an account and answers with the same predicate as REST. An event carrying no account_id cannot be resolved, so it reaches only roles that see every book. /events/system therefore connects and stays empty for a tenant — on top of having no emitter behind it. Failing closed is deliberate: the alternative is leaking exactly the events we cannot reason about.

  • The event log is in-memory, not a replay log

    The stream is served from a per-process log capped at 10,000 events. It does not survive a restart, and the since_id / until_id cursors work within that window only. Reconcile against the REST API rather than treating the stream as your system of record.

  • SQS fan-out is not enabled

    The code paths exist in BaaS and Portfolio Management, but the AWS dependency is in no service image and each would need per-service queue configuration. Talk to us before planning around it.