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 gets403 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:
netContractsis both the amount at risk on the net side and its maximum payout.costBasisis 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
GET /private/v1/positions — list the caller's net positionsReturns 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.
| Query | Required | Notes |
|---|---|---|
eventId | no | Only return positions on this event. |
marketId | no | Only 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:
| Field | Notes |
|---|---|
strike | Spread / total / handicap the position is held at. null for markets without one (moneyline). |
netOutcomeId | The outcome the user is net long. When both sides hold equal contracts this is the lower-numbered outcome and netContracts is 0. |
netContracts | Contracts on netOutcomeId after offsetting the opposing side. |
netCostBasis | Dollars paid for the net contracts at the net side's average entry price. |
markPrice | A 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. |
markValue | markPrice'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
POST /private/v1/positions/quote — quote a saleA 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 (asides[].contractIdfrom 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:
| Field | Notes |
|---|---|
requestedContracts | What you asked for. |
fillableContracts | What the book can fill right now. Less than requested when liquidity is thin. |
bestPrice | Best price available right now (American). |
vwapPrice | Average price across fillableContracts. Equals bestPrice unless the sale has to walk through several price levels. |
floorPrice | The worst price this quote reflects. Pass it back on the close to require that price or better. |
slippagePercent | How much worse vwapPrice is than bestPrice. |
grossProceeds | Total proceeds at vwapPrice, before the fee. |
expectedFeeAmount | The ISV fee on the gross proceeds if the full amount closes. |
netProceeds | grossProceeds minus expectedFeeAmount. |
withdrawableAmount | Portion of netProceeds the user can withdraw right away. |
lockedAmount | Portion of netProceeds held until the market settles. Only nonzero when highRisk is true; released automatically at settlement. |
highRisk | True 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. |
quoteId | Required on the close request. It fixes the fee rate the close will be charged at. |
expiresAt | The 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
POST /private/v1/positions/close — sell the positionSells 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..."
}contractIdandcontracts— must match what the quote was for.floorPrice— worst acceptable price (American). Fills at this or better, or not at all. Normally the quote'sfloorPrice.quoteId— required. A missing, unknown, expired or mismatched quote gets a422with a validation error on thequoteIdfield.
Response (202, ClosePositionResponse):
{ "refId": "q_01HZX...", "status": "pending" }refId— the id to poll. It is the same value as thequoteId, so sending the same quote twice does not open a second sale; it just returns the original close. Reusing aquoteId/refIdagainst a differentcontractIdorcontractsthan it was quoted for is not treated as a resend — it is rejected (seeDUPLICATE_CLOSE_REQUESTbelow).status—pending, or alreadyfilledif it completed right away.
Errors. Non-validation errors look like { "error": "...", "code": "..." }. Validation errors look like { "errors": [ { "field": "quoteId", "message": "..." } ] }.
| HTTP | code | Meaning |
|---|---|---|
400 | INSUFFICIENT_POSITION | contracts exceeds what the user holds on that contract. |
400 | CLOSE_TOO_SMALL / CLOSE_TOO_LARGE | Size outside the allowed range for a sale. |
400 | DUPLICATE_CLOSE_REQUEST | The 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. |
409 | CLOSE_IN_PROGRESS | A sale is already running for this position. |
409 | NO_LIQUIDITY_TO_CLOSE | Nothing resting on the opposing side to fill against. Re-quote. |
409 | CLOSE_FLOOR_UNREACHABLE | The 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
GET /private/v1/positions/close/{refId} — sale statusPoll this until status is terminal.
status | Terminal | Meaning |
|---|---|---|
pending | no | Submitted, nothing sold yet. |
partial | no | Some contracts sold, remainder still being worked. |
filled | yes | All requested contracts sold. |
unfilled | yes | The price moved past floorPrice before anything sold. Nothing changed. |
failed | yes | The 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"
}| Field | Notes |
|---|---|
requestedContracts / filledContracts / unfilledContracts | How much was asked, sold, and not sold. unfilledContracts only settles once status is terminal. |
averagePrice | What the sold contracts actually averaged (American). null until something fills. |
grossProceeds | Proceeds so far, before the fee. |
fee | The ISV fee on the proceeds so far, at the rate that was quoted. A partial fill only pays the fee on what filled. |
withdrawableAmount / lockedAmount | The split of the net (grossProceeds - fee). If highRisk is false, lockedAmount is 0. |
highRisk | Same as on the quote. |
createdAt | When the sale was requested. |
7. What happens to the position and the wallet
- After a full sale the strike disappears from
GET /positions(or showsnetContractsat or near 0). After a partial salenetContractsdrops by exactly the amount sold. - Net proceeds are credited to the user's wallet automatically and show up as a
PAYtransaction inGET /private/v1/transactions, the same as a settlement win would. WhichcontractIdthat 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 ofMatchedOrderEventwebhooks is still in progress. - The original market order is unchanged. Its
winningStatusstill 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
contractIdon the resultingPAYtransaction is not yet deterministic. - No
/pushevent 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
withdrawableAmountsemantics may change.
Updated 17 days ago
