Positions and Cash Out (early close)

Cash out lets a user sell some or all of an open position before the market settles, realizing their profit or loss now instead of waiting for the result. The platform fills the sale against the resting liquidity on the opposing side of the market, so the price the user gets is whatever the book will pay at that moment.

A user sells a position in three calls: get a quote, close using that quote, then poll for the result. A fourth endpoint lists what the user holds so you know how much there is to sell.

Auth: user-scoped on all four, the same as the market order endpoints (sub must equal the acting user's UUID, plus subsig). See Authentication.

Required permission: market-order. All four endpoints are gated on the same user permission as single-contract orders. A user without it gets 403 permission_denied.

Not available on futures and outrights. Cash out will be refused on futures / outrights markets. The exact error for that case is still being finalized.


1. The flow

GET  /private/v1/positions                 ─►  what does the user hold, netted per market strike
POST /private/v1/positions/quote           ─►  what would selling N contracts realize right now
POST /private/v1/positions/close           ─►  202 with refId (same value as the quoteId)
GET  /private/v1/positions/close/{refId}   ─►  poll until status is terminal

Quote → close → poll is the same shape as market orders (estimate → submit → poll). The quote is a preview; the floorPrice you pass on the close is what actually protects the fill price, and the quoteId is what fixes the fee rate.


2. Contracts, not dollars

Sizes on these endpoints are in contracts, not in the dollar quantity a market order uses. One contract pays $1 if the outcome wins and $0 if it loses, so:

  • netContracts is both the amount at risk on the net side and its maximum payout.
  • costBasis is the dollars paid for those contracts.
  • A $25 order at -110 holds roughly 47.7 contracts; the same $25 at +100 holds 50.

To find out how many contracts a user holds, call GET /private/v1/positions and read netContracts (or sides[].contracts for a single outcome). Use that number as contracts on the quote and close.

Prices are canonical American odds, and American is the only format accepted on input. Wherever a price is returned, a companion *Formats object carries the same price in every supported format:

{ "american": -110, "decimal": 1.9091, "cents": 52.38, "probability": 52.38 }

3. GET /private/v1/positions — list the caller's net positions

Returns the caller's open exposure netted down to one entry per market strike. Contracts held on both sides of the same strike offset each other, so a fully hedged strike shows netContracts: 0. The sides array always carries the per-outcome detail behind the net figure.

Only matched (filled or partially filled) contracts count toward a position. Resting unmatched orders are not exposure yet; use GET /private/v1/market-orders for those.

QueryRequiredNotes
eventIdnoOnly return positions on this event.
marketIdnoOnly return positions on this market. Requires eventId — market IDs are not unique across events.

Response (ListPositionsResponse):

{
  "positions": [
    {
      "eventId": 20023797,
      "marketId": 186,
      "strike": -3.5,
      "netOutcomeId": 4,
      "netContracts": 120.00,
      "netCostBasis": 62.40,
      "markPrice": -138,
      "markPriceFormats": { "american": -138, "decimal": 1.7246, "cents": 57.98, "probability": 57.98 },
      "markValue": 69.60,
      "sides": [
        {
          "outcomeId": 4,
          "contractId": "8315d4bc44d06eef736959e0b214c170",
          "contracts": 120.00,
          "costBasis": 62.40,
          "averageEntryPrice": -160,
          "averageEntryPriceFormats": { "american": -160, "decimal": 1.6250, "cents": 61.54, "probability": 61.54 }
        }
      ]
    }
  ]
}

Field meanings:

FieldNotes
strikeSpread / total / handicap the position is held at. null for markets without one (moneyline).
netOutcomeIdThe outcome the user is net long. When both sides hold equal contracts this is the lower-numbered outcome and netContracts is 0.
netContractsContracts on netOutcomeId after offsetting the opposing side.
netCostBasisDollars paid for the net contracts at the net side's average entry price.
markPriceA quick valuation using only the current best price (American). null when fully hedged or nothing is bid. It is not what a sale would realize; use the quote for that.
markValuemarkPrice's per-contract price × netContracts. Ignores book depth, so selling the full size may realize less. 0 when markPrice is null.
sides[]One entry per outcome the user holds contracts on, net side first. contractId is the id you pass to sell that side. Compare averageEntryPrice to markPrice to show an unrealized move.

4. POST /private/v1/positions/quote — quote a sale

A preview of what selling some or all of a position would realize right now against current opposing-side liquidity. Nothing is reserved and the book can move before you close. Treat every value as an estimate; floorPrice is what actually protects the fill price.

Body (QuoteClosePositionRequest):

{ "contractId": "8315d4bc44d06eef736959e0b214c170", "contracts": 50.00 }
  • contractId — the contract being sold (a sides[].contractId from the positions list; same ids as everywhere else).
  • contracts > 0 — how many contracts to quote selling.

Response (QuoteClosePositionResponse):

{
  "requestedContracts": 50.00,
  "fillableContracts": 50.00,
  "bestPrice": -150,
  "bestPriceFormats": { "american": -150, "decimal": 1.6667, "cents": 60.00, "probability": 60.00 },
  "vwapPrice": -140,
  "vwapPriceFormats": { "...": "..." },
  "floorPrice": -122,
  "floorPriceFormats": { "...": "..." },
  "slippagePercent": 2.50,
  "grossProceeds": 29.25,
  "expectedFeeAmount": 0.75,
  "netProceeds": 28.50,
  "withdrawableAmount": 28.50,
  "lockedAmount": 0.00,
  "highRisk": false,
  "quoteId": "q_01HZX...",
  "expiresAt": "2026-10-19T18:00:10Z"
}

Field meanings:

FieldNotes
requestedContractsWhat you asked for.
fillableContractsWhat the book can fill right now. Less than requested when liquidity is thin.
bestPriceBest price available right now (American).
vwapPriceAverage price across fillableContracts. Equals bestPrice unless the sale has to walk through several price levels.
floorPriceThe worst price this quote reflects. Pass it back on the close to require that price or better.
slippagePercentHow much worse vwapPrice is than bestPrice.
grossProceedsTotal proceeds at vwapPrice, before the fee.
expectedFeeAmountThe ISV fee on the gross proceeds if the full amount closes.
netProceedsgrossProceeds minus expectedFeeAmount.
withdrawableAmountPortion of netProceeds the user can withdraw right away.
lockedAmountPortion of netProceeds held until the market settles. Only nonzero when highRisk is true; released automatically at settlement.
highRiskTrue when the market's high-risk designation locks part of the proceeds. This applies to markets with an increased potential of settling as a void or push.
quoteIdRequired on the close request. It fixes the fee rate the close will be charged at.
expiresAtThe quote is only good for about 10 seconds. After that the close is rejected and you must re-quote.

Common failures: 404 (no position on that contract), 422 (validation).


5. POST /private/v1/positions/close — sell the position

Sells the requested contracts at the current opposing-side liquidity, at floorPrice or better, or not at all. Asynchronous: returns 202 immediately.

Body (ClosePositionRequest):

{
  "contractId": "8315d4bc44d06eef736959e0b214c170",
  "contracts": 50.00,
  "floorPrice": -122,
  "quoteId": "q_01HZX..."
}
  • contractId and contracts — must match what the quote was for.
  • floorPrice — worst acceptable price (American). Fills at this or better, or not at all. Normally the quote's floorPrice.
  • quoteId — required. A missing, unknown, expired or mismatched quote gets a 422 with a validation error on the quoteId field.

Response (202, ClosePositionResponse):

{ "refId": "q_01HZX...", "status": "pending" }
  • refId — the id to poll. It is the same value as the quoteId, so sending the same quote twice does not open a second sale; it just returns the original close. Reusing a quoteId/refId against a different contractId or contracts than it was quoted for is not treated as a resend — it is rejected (see DUPLICATE_CLOSE_REQUEST below).
  • status — pending, or already filled if it completed right away.

Errors. Non-validation errors look like { "error": "...", "code": "..." }. Validation errors look like { "errors": [ { "field": "quoteId", "message": "..." } ] }.

HTTPcodeMeaning
400INSUFFICIENT_POSITIONcontracts exceeds what the user holds on that contract.
400CLOSE_TOO_SMALL / CLOSE_TOO_LARGESize outside the allowed range for a sale.
400DUPLICATE_CLOSE_REQUESTThe quoteId/refId was already used for a close with a different contractId or contracts. An exact resend of the same quote is not an error — see above.
409CLOSE_IN_PROGRESSA sale is already running for this position.
409NO_LIQUIDITY_TO_CLOSENothing resting on the opposing side to fill against. Re-quote.
409CLOSE_FLOOR_UNREACHABLEThe book moved and floorPrice can no longer be met. Re-quote.
404—No position on that contract.
422—Validation error, including a bad or expired quoteId.
401 / 403 / 500—Auth, permission, server error.

Partial sales are supported. Closing part of a position does not block closing the rest later; get a fresh quote for the remainder and close against that.


6. GET /private/v1/positions/close/{refId} — sale status

Poll this until status is terminal.

statusTerminalMeaning
pendingnoSubmitted, nothing sold yet.
partialnoSome contracts sold, remainder still being worked.
filledyesAll requested contracts sold.
unfilledyesThe price moved past floorPrice before anything sold. Nothing changed.
failedyesThe request could not be processed. Nothing changed.

Response (CloseStatusResponse):

{
  "refId": "q_01HZX...",
  "status": "filled",
  "requestedContracts": 50.00,
  "filledContracts": 50.00,
  "unfilledContracts": 0.00,
  "averagePrice": -140,
  "averagePriceFormats": { "...": "..." },
  "grossProceeds": 29.25,
  "fee": 0.75,
  "withdrawableAmount": 28.50,
  "lockedAmount": 0.00,
  "highRisk": false,
  "createdAt": "2026-10-19T17:58:12Z"
}
FieldNotes
requestedContracts / filledContracts / unfilledContractsHow much was asked, sold, and not sold. unfilledContracts only settles once status is terminal.
averagePriceWhat the sold contracts actually averaged (American). null until something fills.
grossProceedsProceeds so far, before the fee.
feeThe ISV fee on the proceeds so far, at the rate that was quoted. A partial fill only pays the fee on what filled.
withdrawableAmount / lockedAmountThe split of the net (grossProceeds - fee). If highRisk is false, lockedAmount is 0.
highRiskSame as on the quote.
createdAtWhen the sale was requested.

7. What happens to the position and the wallet

  • After a full sale the strike disappears from GET /positions (or shows netContracts at or near 0). After a partial sale netContracts drops by exactly the amount sold.
  • Net proceeds are credited to the user's wallet automatically and show up as a PAY transaction in GET /private/v1/transactions, the same as a settlement win would. Which contractId that row carries is not yet pinned down (it may be the sold side or the opposing side), so match on amount and timing rather than contract for now.
  • Under the hood the platform sells by placing a synthetic reduce-only order on the market's other outcome. That order is not something the user placed, and it is filtered out of GET /private/v1/market-orders. Filtering it out of MatchedOrderEvent webhooks is still in progress.
  • The original market order is unchanged. Its winningStatus still flips at settlement, but the sold contracts no longer contribute exposure or payout.
  • There is no webhook for sale status yet. Poll GET /positions/close/{refId}.

8. Curl

BASE="https://isv-api.sandbox.prophetx.dev/private/v1"

# 1. What does the user hold on this event?
curl "$BASE/positions?eventId=20023797" -H "Authorization: Bearer $JWT_USER"

# 2. Quote selling 50 contracts — grab floorPrice and quoteId from this response
curl -X POST "$BASE/positions/quote" \
  -H "Authorization: Bearer $JWT_USER" \
  -H "Content-Type: application/json" \
  -d '{ "contractId":"8315...", "contracts":50 }'

# 3. Sell at floorPrice or better, within ~10s of the quote
curl -X POST "$BASE/positions/close" \
  -H "Authorization: Bearer $JWT_USER" \
  -H "Content-Type: application/json" \
  -d '{ "contractId":"8315...", "contracts":50, "floorPrice":-122, "quoteId":"q_..." }'
# → 202 { "refId":"q_...", "status":"pending" }

# 4. Poll until status is filled / unfilled / failed
curl "$BASE/positions/close/q_..." -H "Authorization: Bearer $JWT_USER"

9. Known gaps in the current implementation

Tracked internally; listed here so you are not surprised:

  • Synthetic closing orders may still appear in matched-order webhooks.
  • The contractId on the resulting PAY transaction is not yet deterministic.
  • No /push event for sale status; polling only.
  • The futures / outrights refusal error is not yet finalized.
  • Interaction with the exchange's exposure / credit features is still being designed, so withdrawableAmount semantics may change.

Did this page help you?