Skip to main content

Overview

Portal integrates with Agg so your users can trade event contracts across every major prediction market from one API. Agg aggregates Polymarket, Kalshi, Limitless, Myriad, Predict, Hyperliquid and others, routes each order to the best price, and bridges funds between chains when the venue settles somewhere the user is not funded. Portal proxies the Agg API server-side with the credentials you store in the Portal Dashboard, and holds each end user’s Agg session so your app never handles an Agg token.
Unlike most Portal integrations, you sign up with Snag directly and sign their Master Service Agreement. Portal is not a party to it. Read Compliance before you ship.

How it works

Agg holds a managed balance per user, funded by sending stablecoins to a deposit address. Trades draw from that balance, and Agg bridges between chains as needed, so a user funded on Base can trade a venue that settles on Polygon. A trade is two calls. You quote an outcome, which prices it across every venue and returns a quoteId, then you fill that quote. Execution is asynchronous, usually under a minute, and you poll for the result. By default, users never sign a transaction to place an order: Agg executes from the managed balance. They sign twice around the edges, once to link their wallet at sign-in, and once per on-chain transfer when funding the balance. If you would rather trade straight from the user’s Portal wallet, see Self-custody fills.

Markets, events and outcomes

Agg models prediction markets in three layers: You quote an outcomeId, which is venueMarketOutcomes[].id. Not the market id, not the event id. GET /events embeds the whole tree, so one call gives you everything to render a browse UI; GET /markets queries the market layer directly when you do not need the event. For the rest of a market UI: Markets from GET /markets and from the bootstrap carry the constraints an order must meet: tickSize, minOrderSize and the fee fields (feeRate, feeExponent, feeBasis). The markets embedded in GET /events leave these out, so read them from /markets or the bootstrap before building an order form. GET /events filters on status (for example status=open) and categoryIds. List responses share one shape, with the page under data:
Pass limit, then cursor=<nextCursor> while hasMore is true. The bootstrap’s markets.nextCursor continues on GET /markets?venueEventId={eventId}&cursor=.
The discovery feed includes resolved markets alongside open ones. Filter for an open event and an open market before offering a trade; a quote on a resolved market returns status: "invalid_input".

Setup

1. Create an Agg app

  1. Sign up at admin.agg.market and create an app.
  2. Under Configure → Domains, add the domain your users sign in from. Portal reads this list and uses it in the sign-in message, so an app with no approved domain cannot link users.
  3. Under Settings, turn on Require an API key on every request. This is required for the trusted proxy below.
  4. Under Configure → Fees, set a fee recipient wallet per settlement chain (EVM, Solana, HyperCore). Venues without a wallet will not pay you.
  5. Under Develop → API, copy your App ID, create an API key, and note the signing key.
Agg’s own browser SDK and components call Agg directly from the browser, so they are not used on an app with Require an API key on. Portal’s integration covers the same flows server-side. If you also want Agg’s components, use a separate Agg app for them. The setting stays on once trusted proxy is enabled.

2. Ask Snag to enable trusted proxy

Agg applies each venue’s location rules using the end user’s IP. Because Portal calls Agg from Portal’s servers, trusted proxy is what lets Agg use the user’s IP that Portal forwards rather than Portal’s own. Email support@agg.market with your App ID, confirming that requireApiKey is on and that Portal forwards the end user’s IP. Portal strips any inbound x-agg-client-ip and sets it from the connection it observes.
Before trusted proxy is enabled, quotes still price normally, but every venue carries unavailableReason: "geo_blocked" and fills do not go through. If fills fail during setup, check this first.

3. Enable the integration in Portal

  1. Open the Portal Dashboard and go to Integrations.
  2. Add Agg by Snag and paste your App ID, API key and signing key.
  3. Save. Integrations are environment-scoped, so repeat per environment.

Linking a user

Before a user can trade, their Portal wallet must be linked to an Agg account. Two calls, with a signature between them.
The response carries the message and signParams, a two-element array of the hex-encoded message and the signing address, ready to pass straight through.
params is the signParams array verbatim. share is the client’s own secp256k1 signing share, which Portal never sends and you must supply here. The response data is the signature.
If you use a Portal SDK, sign with the SDK’s request method, passing personal_sign and the signParams. The SDK supplies the share, so you never handle it directly. The raw call above is for server-side callers holding the share themselves.
Portal stores the resulting session encrypted, refreshes it automatically, and binds your Portal client id to the Agg account as an external id so you can correlate the two.
GET /link reports the stored link, not a live session. It can return linked: true while the next call returns AGG_LINK_REQUIRED because the Agg session has expired. Branch on the error id, never on linked alone, and re-run the link flow when you see it.

Account-abstracted clients are not supported

Agg signs users in with an ECDSA signature from the wallet’s own key (an EOA), so a smart account cannot link. Portal returns 400 AGG_ACCOUNT_ABSTRACTION_UNSUPPORTED on the link and trading routes for an account-abstracted client. Discovery and market data (/events, /markets, /search, /categories, /charts/*, /venue-geo-policy, /app-config, /midpoints, /orderbooks) stay open, so an AA client can still browse and render markets.

Funding

Returns one EVM address and one Solana address, plus the chains and tokens accepted on each. Like every chain id in Agg responses through Portal, supportedChains[].chainId is CAIP-2 where the chain has one; a chain without one, such as Hyperliquid (1337), keeps Agg’s id. Wallets are provisioned lazily, so the first call can return { "ready": false }. Poll until it is ready.
Fetch the deposit address each time you need it. Agg can reassign the managed wallet of an inactive user and issue a new address on the next request, so read it immediately before showing an address or building a transfer rather than caching it.
Funding is a plain token transfer to that address. Portal is not in the path. Each venue settles on its own chain, so where you fund determines which venues are cheapest to reach: Agg bridges between them automatically. Funding the chain your users trade most keeps bridge fees down.
Polymarket settles in pUSD. When a Polymarket order needs more pUSD than the user holds on Polygon, Agg bridges it in from another chain, and that cost appears in fees.actual.bridge on the order.

Trading

Then poll GET /orders/{quoteId} until terminal: true, honouring the pollAfterMs the response asks for.
Check availability as well as price. A quote prices every venue, including ones this user cannot trade on, so status: "ok" tells you pricing succeeded. Before offering a trade, check both that status is ok and that warnings[] is empty or absent. Per venue, branch on unavailableReason; venueSoloQuotes[].status describes whether the venue could price the trade, not whether the user may take it.
Quotes expire about 45 seconds after creation, with refreshAt at 15 seconds as the re-quote hint. Do not show a price for longer than that. A quote also tells you what the user nets and why a venue could not quote:
  • estimatedNetProceeds is what a sell returns after fees, and positionAvailability.totalSellableShares how much the user can sell.
  • quoteFailure names the venue and reason when a venue could not quote, with retryAt when it is worth asking again.
  • venueSoloQuotes[] shows what each venue alone would do. If the routed quote is not fillable but one venue is, quote again with venues set to that venue.
  • Orders below a venue’s minimum come back as status: "min_order_size_violated" before anything is submitted.
For limit orders, size in multiples of the market’s sizeStepRaw and at least its minSizeRaw, both 1e6-scaled share amounts. GET /orderbooks returns them per market under requestedMarket.

Self-custody fills

A fill can spend from the user’s own Portal wallet instead of the managed balance. Quote with custody: "self" so the price reflects that wallet, then send custody: "self" with the fill:
Portal names the wallet the user linked as the signer, so the fill always draws on that user’s own wallet. Agg then prepares what needs signing asynchronously:
  1. Poll GET /orders/{quoteId} and read pendingSignatures[].
  2. Sign each entry with the Portal wallet, by its type. For eip712, call eth_signTypedData_v4 with params: [signerAddress, JSON.stringify(payload)]. For personal_sign, use personal_sign. For transaction, broadcast it with eth_sendTransaction and keep the hash. Agg can also ask for hl_l1_action, eip7702_authorization and safe_tx steps, depending on the venue and route. An entry with optional: true can be skipped.
  3. Send the answers back. Each carries the entry’s stepId and either the signature or, for transaction, the txHash.
The response carries the next batch of pendingSignatures, empty once the fill needs nothing more. Each entry has an expiresAt, so answer promptly.
Cross-chain self-custody fills need a token approval for the bridge. By default (approveMode: "sponsored") the wallet signs a one-time EIP-7702 authorization over a raw digest instead of sending the approval itself. If your signing flow does not produce that, send approveMode: "user_broadcast" with the order, and the user broadcasts the approval and pays its gas.

Limit orders and cancelling

POST /orders/limit places a resting order, which is the only kind POST /orders/cancel can act on. A market order fills or fails immediately. POST /orders/direct is a market order on one named venue without a quote. It takes venue, venueMarketOutcomeId, side, externalId (your idempotency key, up to 36 characters, so a retried request returns 409 rather than trading twice), one of maxSpend (USD, on a buy) or sellShares, and optionally slipCapBps.
limitPriceRaw and sizeRaw are positive integer strings scaled by 1e6: 200000 is a price of 0.20 and 5000000 is five shares. On the read side, GET /orders returns quotedPriceRaw as a decimal string like "0.5", while limitPriceRaw and averageFillPriceRaw keep the 1e6 scale. Each row’s id is id. Portal validates the write side and tells you which is wanted.
Cancel takes the order id, not the quoteId: orderIds[] on the fill, or id on a GET /orders row. The reservation is released in full.
If Agg bridged funds to back a limit order, cancelling it releases the reservation and leaves those funds on the venue’s chain, ready for the next order there. The bridge fee is not refunded.
Polymarket has a $1 minimum on orders that trade immediately. The whole order (price x size) must be at least $1, even if only part of it fills. The venue applies this after submission, so a smaller order moves from pending to failed with the reason in errorMessage. Validate price x size in your UI before submitting.

Order statuses

GET /orders reports each order’s status: A partial_fill looks the same whether the venue cancelled the remainder (a FAK order) or the user did. partialFillReason is venue_cancelled in both cases; cancelRequestedAt is set only when the user cancelled. On a final order, remainingSizeRaw and reservedCostRaw keep their last values, so read filledSizeRaw and filledCostRaw for what happened and GET /balances for the released funds. Cancel can return cancel_pending or cancelled from either venue, so handle both.

Positions, resolution and redemption

GET /positions returns open and resolved positions. When a market resolves in the user’s favour, redeemStatus flips from ineligible to eligible and the winning leg carries winner: true.
The id redemption wants is venueMarketOutcomes[].venueBreakdown[].venueMarketOutcomeId from the position, which is the venue’s outcome id. It is not the outcomeId you quoted against and cannot be reconstructed from the order.
Redemption pays out at par with no fee and credits the chain the venue settles on. It usually completes in seconds.

Withdrawals

Four routes, under the same base path:
amountRaw is in the destination token’s base units: 6 decimals for USDC, 18 on BNB Chain. The withdrawal quote returns decimals for the destination you ask about. destinationChainId is a CAIP-2 chain id, such as eip155:8453, or solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp for Solana mainnet (the solana:mainnet alias also works). Send max: true alongside amountRaw to cap the withdrawal at what can actually be delivered. destinationAddress defaults to the user’s own Portal address for that chain. Any other address must be on your environment’s destination allowlist for Agg, otherwise the request returns 403; add entries in the Portal Dashboard or through the Custodian API. Pass requestId, a UUID, to make an exact-amount withdrawal replayable: the same parameters return the original withdrawal instead of dispatching twice, and changed parameters are rejected. It cannot be combined with max.
legs[] on the response is the only place a transaction hash appears, one per hop. A same-chain withdrawal has one leg; a bridged one has several.

Balances are per chain, not one pot

GET /balances reports an aggregate availableRaw across chains, but that is not how much a user can spend on a given trade. Spendability depends on which chain holds the funds and the route Agg chooses, and when a chain already holds some balance Agg may draw on that chain alone. So a quote can return Insufficient balance: requested \$2, have \$0.00 while the aggregate shows several dollars. The same applies on the way out: POST /withdrawals/quote returns a rawBalanceRaw that is destination-dependent. A Base or Polygon destination may not be able to reach a balance sitting on BNB Chain, while a BNB destination can reach everything. Always quote the specific destination rather than reading the aggregate.

Geo restrictions

Every venue restricts countries, and the lists differ. The United States is blocked on all of them.
  • GET /venue-geo-policy returns the current per-venue blocked lists. Venues update them, so read it live rather than caching it.
  • A venue can exist without appearing in that response, so do not treat it as the full venue list.
  • isLocationBlocked on the Agg user profile reflects app-level policy only, meaning OFAC plus whatever you configure. It reads false for a user every venue blocks. It is not a “can this user trade” flag.
Trading is geo-gated. Redemption and withdrawal are not, so a user travelling somewhere restricted can still collect winnings and move funds out.

Forwarding the end user’s IP

Agg resolves the country from the caller’s IP. Portal forwards it automatically:
  • SDK traffic reaches Portal from the user’s device, so Portal observes it.
  • Server-to-server callers must send X-Portal-End-User-IP, because Portal never sees the end user. Without it every one of your users resolves to your backend.
It must be a single public IPv4 or IPv6 address. Portal rejects lists and private ranges with a 400, so a malformed value surfaces right away instead of resolving the user to the wrong country.

Market data

GET /midpoints and GET /orderbooks take a comma-separated venueMarketIds and return live prices. Without them every market renders at 50/50.
arbReturn on an event and the matched array are discovery signals, not trade instructions. arbReturn can sum across related markets that are mutually exclusive, such as “Democrats Sweep” and “Republicans Sweep”, and cross-venue matching groups outcomes that describe the same event. Quote the specific outcome before presenting a price or return to users.

Charts

GET /charts/bars?venueMarketOutcomeId={outcomeId}&to={unixMilliseconds} returns OHLCV bars for one outcome. resolution is 1, 5, 60 (minutes) or 1D, and countBack asks for a number of bars ending at to. All chart times are Unix milliseconds, including to, from and each bar’s t. For long ranges, GET /charts/history/manifest takes the same parameters and returns up to eight CDN chunks of finalized history. Fetch each url directly, check it against sha256, then fill in everything after finalizedThrough from /charts/bars. This is the path the bootstrap’s references.historyManifestPath names, and its selectedOutcomeIds are the outcomes to chart first. meta.state tells you what to do next:

Errors

Errors carry a message, and Portal’s typed errors also carry a stable id to branch on:
Test against staging at https://api.portalhq.dev before going live; every path is the same. Each response carries an x-trace-id header, which is what Portal support asks for.

Compliance

Your users trade through your Agg app under your agreement with Snag. Portal provides the integration; it is not a party to that contract.
Under Snag’s Master Service Agreement, geofencing is the subscriber’s sole responsibility, and jurisdictional breach is excluded from the liability cap. The prohibited list is broader than the per-venue geo policy and includes Arizona and Nevada domestically. Review the agreement and take your own legal advice before going live.
You are also the reporting entity for AML and KYC for your users, and you must pass Snag’s mandatory end-user terms through into your own terms of service. Live trading opens once the partner agreement is signed in the Agg dashboard. Before that, an app runs with a limit of 20 users and 100 trades, which is enough to build and test against.

Endpoint reference

All routes are under https://api.portalhq.io/api/v3/clients/me/integrations/agg/ and authenticate with Authorization: Bearer [clientApiKey|clientSessionToken].

Webhooks

Register your webhook URL directly with Agg in their dashboard and verify deliveries with your signing key. Portal is not in the delivery path, the same as other Portal integrations.