Skip to content

GuidesTrading & accounts

A conditional order attaches a trigger rule to an execution you have already built, so the order waits for a price instead of resting on a venue. When the rule is satisfied, Predictefy either tells you to submit it or — on the one venue where that needs nothing of yours — submits the body you already signed.

Arming builds nothing, signs nothing, and relays nothing. It takes an execution that is still built and records what should happen to it later.

Conditional orders require Pro or above, with these per-account limits (live since 2026-09-11):

PlanConditional orders armed at once
FreeNot included
BuilderNot included
ProUp to 25
EnterpriseNo cap

Only conditional orders currently in armed status count towards the cap. Reaching the cap returns 409 CONDITIONAL_ARMED_CAP, with a message naming the cap and your plan. Cancel an armed order or upgrade before arming another. A plan below Pro receives 403 PLAN_REQUIRED, with Pro named as the minimum plan in the message.

Hosted firing stays off for every plan. See the plan table.

There is no single “stop-loss” feature here. Which mode a venue can serve is derived from what that venue’s own submit requires, and GET /v1/exec/venues reports it per venue as conditional: { native, hosted, client }.

ModeWho watches the priceWho submits when it firesWhere it works
nativeThe venue’s own engineThe venueHyperliquid only
hostedPredictefyPredictefy, replaying your signed bodyLimitless only
clientPredictefyYou, with your credentialsEvery venue
  • native is not a conditional order at all — it is a venue-held trigger order you sign once. On Hyperliquid, pass trigger.triggerPx and trigger.tpsl in the ordinary build body and Hyperliquid’s engine watches and fires. See the Hyperliquid build schema. Nothing on this page is involved.
  • hosted is limited by custody, not by ambition. A hosted fire replays a stored submit body with no caller present, so it is offered only where every credential the venue requires is a client-side wallet signature, and the lane declares that its submit carries no time-bound permit and no caller secret. Both gates must hold. Today that is Limitless alone: Rain never posts (your client broadcasts its own transaction), Pascal’s stored permit carries a staleness window a delayed fire would trip, and every remaining venue needs an API key, a session bearer, or an OAuth pair at submit time — none of which we will ever hold.
  • client is always available, because watching a book and telling you costs us nothing of yours. You submit with your own credentials when the trigger fires.

Read the modes off the API rather than off this table. Execution lives on its own origin, separate from the reads API — https://exec.predictefy.com, which the examples below call $PREDICTEFY_EXEC_URL:

Terminal window
curl -s "$PREDICTEFY_EXEC_URL/v1/exec/venues" \
-H "Authorization: Bearer pk_live_YOUR_KEY" \
| jq '.data[] | {venue, conditional}'
armed ──▶ triggered ──▶ fired (client: you submit, then report it fired)
│ ├──────▶ fire_failed (client: you report the venue never took it)
│ ├──────▶ canceled (client: you withdraw, until a reservation fences it)
│ └──────▶ firing ──▶ fired | fire_failed (hosted, once firing is on)
├──▶ canceled (you cancelled it, or an OCO sibling triggered first)
├──▶ expired (expiresAt passed while still armed)
└──▶ superseded (the execution was already advanced by something else)

Every transition is appended to the order’s own events trail, with the evidence that caused it — the record that answers “why did this fire”. Read it with the single-order route.

Every route needs an API key with the trade scope; new keys default to read, so opt into trading per key in the console. All three POSTs require an Idempotency-Key.

RouteWhat it does
POST /v1/exec/{venue}/orders/{executionId}/armAttach a trigger rule to a built execution (201)
GET /v1/exec/conditionalYour conditional orders, newest first, cursor-paged
GET /v1/exec/conditional/{id}One order plus its event trail
POST /v1/exec/conditional/{id}/cancelCancel a still-cancellable order, erasing any artifact
POST /v1/exec/conditional/{id}/reportClient mode: report the outcome — fired, or not_submitted

The path executionId must name one of your own executions that is still built; a submitted one has already spent its artifact and is refused. It must also be an order execution (intent: order) — a cancel, modify or redeem execution is refused with 400, because only an order can be a conditional’s leg. expiresAt is required. When the built artifact names only a venue ticker (Kalshi does), also pass the catalog marketId and outcomeId so the trigger can be bound to a book — without them the arm is refused with 400.

Terminal window
curl -s -X POST "$PREDICTEFY_EXEC_URL/v1/exec/kalshi/orders/$EXECUTION_ID/arm" \
-H "Authorization: Bearer pk_live_YOUR_KEY" \
-H "Idempotency-Key: 9d2f0f1c-arm-1" \
-H "content-type: application/json" \
-d '{
"fireMode": "client",
"marketId": "KXFEDDECISION-26SEP-H0",
"outcomeId": "KXFEDDECISION-26SEP-H0",
"trigger": { "tpsl": "sl", "price": 0.40 },
"expiresAt": "2026-11-01T00:00:00.000Z"
}'
import { Predictefy, PREDICTEFY_EXEC_BASE_URL } from '@predictefy/sdk';
const client = new Predictefy({
apiKey: process.env.PREDICTEFY_API_KEY,
execBaseUrl: PREDICTEFY_EXEC_BASE_URL,
});
const armed = await client.exec.armConditionalOrder({
venue: 'kalshi',
executionId,
fireMode: 'client',
// Kalshi's stored artifact names only the venue ticker, so the catalog ids come from the
// caller — without them the arm is refused with 400.
marketId: 'KXFEDDECISION-26SEP-H0',
outcomeId: 'KXFEDDECISION-26SEP-H0',
trigger: { tpsl: 'sl', price: 0.4 },
expiresAt: '2026-11-01T00:00:00.000Z',
idempotencyKey: '9d2f0f1c-arm-1', // reuse this exact key on a retry
});
from predictefy import Predictefy, PREDICTEFY_EXEC_BASE_URL
client = Predictefy(
api_key="pk_live_YOUR_KEY",
exec_base_url=PREDICTEFY_EXEC_BASE_URL,
)
armed = client.exec.arm_conditional_order(
"kalshi",
execution_id,
{
"fireMode": "client",
# Kalshi's stored artifact names only the venue ticker, so the catalog ids come from
# the caller — without them the arm is refused with 400.
"marketId": "KXFEDDECISION-26SEP-H0",
"outcomeId": "KXFEDDECISION-26SEP-H0",
"trigger": {"tpsl": "sl", "price": 0.40},
"expiresAt": "2026-11-01T00:00:00.000Z",
},
idempotency_key="9d2f0f1c-arm-1", # reuse this exact key on a retry
)

Idempotency-Key is required and neither SDK mints one for you here: a fresh key per retry would arm a second conditional order on the same execution. Reuse the same key and a repeat returns 200 with the order you already armed, plus Idempotency-Replay: true.

when and price are the rule; everything else has a default that is stored explicitly on the armed row, so an order always says exactly what it meant.

FieldDefaultWhat it means
whenrequiredat_or_above or at_or_below — the direction the reference must cross
pricerequiredA probability in (0, 1), on the 1e-6 grid (max six decimals)
referencemarkmark, bid, or ask
confirmations2Consecutive qualifying samples before it triggers
dwellMs2000How long the condition must hold, measured on book timestamps
maxSpread0.10No trigger while the spread is wider than this; same 1e-6 grid
minSize1The qualifying quote must show at least this size
tpsl—Sugar: tp or sl, rewritten into when from your order’s side

The mark is the median of the best bid, the best ask, and the last trade — but only while that trade is fresh. A stale last trade is excluded and the mark falls back to the mid of bid and ask, so one old print cannot drag a trigger. A book with only one side never triggers at all: without both quotes there is no mark and no provable spread.

confirmations has one documented shortcut: a single qualifying sample whose displayed size at the touch grew since the previous sample counts as confirmed on its own, because real interest arriving at the touch is worth as much as a second sample. dwellMs still has to be satisfied either way.

tpsl is sugar for people who think in stops rather than in directions. A stop protects a position, so on a sell it becomes at_or_below and on a buy at_or_above; take-profit mirrors it. The rewrite reads the side out of your stored order, so if that order does not name a side you must give when yourself.

price and maxSpread must land on the engine’s 1e-6 tick grid, which is to say at most six decimals. The engine compares in whole ticks, so an off-grid rule could never mean what it says: 0.4100004 would trigger at 0.41, and 0.4050005 would never match at all.

Every bound above is a rejection, never a clamp. An out-of-range or off-grid price, maxSpread, or confirmations is a 400 VALIDATION_ERROR, not a silently corrected value — a rounded trigger is a different trigger from the one you asked for, and you would never be told.

expiresAt must be in the future, within a year, and — when our catalog knows the market’s close date — at or before it. An order that outlives its market can never fire, so we refuse to arm one. When it passes while the order is still armed, the order moves to expired and emits conditional.expired.

An execution can own at most one live conditional order — one that is armed, triggered, or firing. Arming a second answers 409 CONDITIONAL_ARMED: two rules on one intent are two answers to the same question, and whichever fired second would find the artifact already spent. Retrying your original arm with the same Idempotency-Key still replays, because the key is read first.

An execution you closed by reporting not_submitted accepts no new order either, in either fire mode: that report says nothing reached the venue, so arming again answers 409 CONDITIONAL_CLOSED_NOT_SUBMITTED. Build a new execution instead.

Pass the same ocoGroupId (a uuid you choose) on two or more armed orders and they become one-cancels-other: when one triggers, its still-armed siblings move to canceled in the same transaction, each with an oco event naming the order that triggered. A sibling that has already settled is left alone.

A group accepts new members only while every member is still armed. Once one has triggered, fired, been canceled, or expired, the group has resolved and joining it answers 409 OCO_GROUP_CLOSED — start a new group instead. Groups are scoped to your account, so the uuid you pick is never visible to, and never closeable by, anyone else.

Hosted mode additionally takes artifact: the already-signed submit body you would have posted yourself. It is encrypted at rest and never returned by any read.

It carries only the fields that venue’s submit actually reads, each in the shape that submit expects — and it must carry the ones that submit cannot run without. On Limitless owner and signature are both required: owner an address string, signature a 65-byte 0x hex ECDSA signature. A 32-byte hex value is a wallet private key, not a signature, and we refuse it as one rather than encrypting it and keeping it. executionId is optional and must match the order you are arming.

Everything else is refused, and that deliberately includes the order: we relay the order we stored when you built it, which is what stops a signature being reused for a different trade. An object or an array where a string belongs is refused too, because a wrapper is where data hides.

Two more refusals back that up — a credential field at any depth, matched however you spell it (apiSecret, api_secret and API-SECRET are one field), because the hosted lane exists precisely because its venue needs none; and anything nested more than six levels deep, because storing what we cannot inspect is the same risk as storing a credential.

A venue that is not hosted-eligible answers 400 HOSTED_FIRE_NOT_ELIGIBLE. A deployment holding no artifact key answers 503 HOSTED_FIRE_UNAVAILABLE, and client arming keeps working — that is a capability gap, not a caller error.

In client mode Predictefy watches the book and tells you; you submit. Both SDKs ship the loop, and both hand each order to your callback exactly once for as long as the loop runs.

const controller = new AbortController();
await client.exec.serveConditional({
pollMs: 2000,
signal: controller.signal,
onTriggered: async (order) => {
// Submit with your own credentials, exactly as you would any built execution.
const submitted = await client.exec.submitOrder({
venue: order.venue,
executionId: order.executionId,
signature,
owner,
});
// Then close the loop so the order's trail is complete.
await client.exec.reportConditionalOrder(order.id, {
executionId: submitted.executionId,
venueRef: submitted.venueRef ?? undefined,
});
},
});
import threading
stop = threading.Event()
def on_triggered(order):
submitted = client.exec.submit_order(
order["venue"],
{"executionId": order["executionId"], "signature": signature, "owner": owner},
)
client.exec.report_conditional_order(
order["id"],
execution_id=submitted["executionId"],
venue_ref=submitted["venueRef"],
)
client.exec.serve_conditional(on_triggered, poll_s=2.0, stop=stop)

Both loops poll for your triggered client-mode orders and stop the moment you ask them to — aborting the signal, or setting the stop event, ends the wait immediately instead of sitting out the rest of the poll gap. Dedupe is in memory and lives as long as the call, so a restarted loop sees still-triggered orders again.

Polling the route yourself is the same two filters: status=triggered for the orders that are waiting on you, and fireMode=client to keep hosted orders — which you can do nothing about — out of the page. fireMode accepts all (the default), client, or hosted, and rejects anything else rather than silently ignoring it.

Report is what closes a triggered order, and only a triggered client-mode order accepts one. It takes an outcome:

  • "fired" — the default. You submitted; name the execution it became. The order moves to fired.
  • "not_submitted" — the venue never accepted it. The order moves to fire_failed, and its executionId is left as armed. Send neither executionId nor venueRef with it, because there is no submission to point at.

not_submitted is judged on the evidence the venue left on your execution, never on its status word. It answers 409 CONDITIONAL_NOT_ARMED when that execution carries a reference the venue itself issued — whatever its status reads now — when a relay is in flight or acknowledged (submitted, acked, filled), or when a reservation for it was consumed. Report fired in that case; our own record contradicts the claim that nothing reached the venue.

It is accepted everywhere else: an execution still built, and a signed, failed, canceled or expired execution that never earned a venue reference. A venue business rejection has exactly that shape — we stamped the submission time and the venue said no. A refusal never earns a reference on any lane: not the digest we derived from your order before sending it, and not an identifier the venue’s own error body happens to echo back. Only an order the venue took records one.

That holds however the venue says no. Some venues refuse with an error status; others answer 200 and carry the refusal inside the body — Polymarket’s success: false, XO’s success: false, Opinion’s non-zero errno, PRED’s failed state, Myriad’s rejected state, PredictStreet’s REJECTED state, Kalshi’s rejected and failed states, Novig’s REJECTED state. Both are the same event, so both record no reference. Kalshi is the one defensive entry in that list: its published contract lists no rejected or failed order state, so the lane maps both in case one ever arrives, and the rule applies if it does. That holds whoever minted the identifier: a digest we derived before sending, and an id the venue itself issued for an order it then refused, are equally worthless, because neither names an order resting on the book. Refusing your cancel counts too: a cancel the venue turned down leaves no reference either, in either of those encodings.

Be plain about what that costs you: a refused order records no venue identifier as a reference. Not the digest we derived before sending, and not one the venue’s own error body echoes back — venueRef is null. The venue refused it, so nothing exists to refresh or cancel and no identifier of ours would name anything real. What the execution’s error carries is the venue’s own rejection reason, sanitized the way every venue reason is; where the venue’s own sentence happens to name its identifier, that sentence is kept as the venue wrote it. A venue message that embeds our own post-acceptance wording is not kept — it is reduced to a bounded sentence of ours (rejected by venue, or venue request timed out when the venue’s text also names a timeout, which is judged first). The report fence reads venueRef, never error, so an identifier sitting inside a reason changes nothing it decides.

The reference is also what tells the two events apart. A failed execution that does carry one failed after the venue accepted your order, and its error says exactly that rather than calling it a rejection — PredictStreet’s SETTLEMENT_FAILED is the case you are most likely to meet. A refusal carries no reference, and its error is the venue’s own sanitized reason.

Two answers follow from that, and they are worth knowing before you meet them. A rejected execution has no venue order id, so POST /v1/exec/{venue}/orders/{executionId}/refresh answers 400 — cannot refresh: the execution has no venue order id yet. And you cannot name a terminal execution — failed, canceled or expired — as the target of a cancel build: that answers 409 EXECUTION_TERMINAL, before the venue’s lane builds anything, because nothing of that order rests at the venue to call back. The status is what decides it, not whether a particular venue happens to keep some other handle on the order.

Neither is a loss: the venue refused the order, so there is nothing resting to read or to call back. What you do instead is report not_submitted and build a fresh order.

Rain is the relay-less lane, and it is the exception worth knowing. We never call a venue for it: submit verifies the transaction you signed and records the hash derived from your own bytes, so the venueRef on a signed Rain execution is your artifact rather than anyone’s answer, and it never refuses your report.

Be plain about what that means today: nothing on our side watches the chain for you. A Rain execution stops at signed and no path we run moves it on — the background reconciler and the status refresh both advance submitted and acked rows only, so a receipt we read is never written back to the row. not_submitted on Rain is therefore accepted on your word alone, and the trail records it as yours. The refusal below it is written for the day that changes: an execution we had actually observed acked or filled would contradict you, and today no writer puts a relay-less execution into either state.

Predict.fun’s on-chain cancel path records a self-derived hash the same way, but its order and cancel relays do reach the venue and record the venue’s own order id, so Predict.fun is treated as the relay venue it is.

An accepted not_submitted closes the execution off as well as the order. Every live, unconsumed reservation on it is handed back — a token you still hold then answers 409 RESERVATION_RELEASED rather than telling you to fetch another — and a later /reserve, /submit, /ack or /arm of that execution answers 409 CONDITIONAL_CLOSED_NOT_SUBMITTED. Build a new execution if you decide to trade after all. A conditional closed any other way — canceled, expired, or fired — leaves its execution usable.

Terminal window
curl -s -X POST "$PREDICTEFY_EXEC_URL/v1/exec/conditional/$CONDITIONAL_ID/report" \
-H "Authorization: Bearer pk_live_YOUR_KEY" \
-H "Idempotency-Key: 9d2f0f1c-report-1" \
-H "content-type: application/json" \
-d '{ "outcome": "not_submitted" }'

Reporting is safe to retry: repeating the same report returns 200 with Idempotency-Replay: true and records nothing twice, while reporting a different execution, a different venueRef, or a different outcome is a different claim about what happened and answers 409 CONDITIONAL_NOT_ARMED.

If you would rather be pushed than poll, register a webhook instead — the events below carry the same information.

A direct submit, ack, or reservation of the execution is refused with 409 CONDITIONAL_ARMED while the order is still armed, in either mode: you told us to wait for a price, so acting on it by hand now would be two different instructions for one intent. Cancel the conditional first if you want to submit it yourself instead.

Once a client order has triggered the refusal lifts, because submitting — and reserving in order to submit — is exactly what you were just told to do. A hosted order that has triggered or is firing keeps refusing: the fire loop is about to replay your signed body, and firing it by hand as well would spend the same signature twice.

Cancelling follows the same line, with two boundaries. An armed order cancels in either mode, and a triggered client order still cancels — nothing but you fires it — until either boundary is crossed.

The first is the execution itself. Cancel needs it to still be built; once anything has advanced it, including a relay the venue then rejected as failed, cancel answers 409 CONDITIONAL_NOT_ARMED.

The second is a reservation. Because your venue submit never passes through this API, a reservation you could still be acting on is the last point at which we can be sure nothing is live: one that is unexpired and unconsumed, one that was consumed however long ago, or any taken since the order was armed. A reservation that predates the arm and has already lapsed does not fence, so an abandoned reservation never makes a later order uncancellable.

Past either boundary, cancel points you at report and the order stays triggered. Report is what closes it — with fired if you got it in, or not_submitted if you did not, including when the venue rejected your submit. A triggered or firing hosted order belongs to the loop and answers 409 CONDITIONAL_NOT_ARMED too, as does one that has already fired or expired.

Cancelling is idempotent: an already-canceled order returns success unchanged, and two cancels that race each other both succeed rather than one taking a spurious conflict.

All four are tenant-scoped: they reach only endpoints owned by the order’s account. Register them the same way as any other event — see Webhooks.

EventFires when
conditional.triggeredThe rule was satisfied — in client mode, your cue to submit
conditional.firedThe order became a venue submission
conditional.fire_failedA hosted fire ended without a venue submission, or you reported not_submitted
conditional.expiredexpiresAt passed while the order was still armed
{
"conditionalId": "0f2c9a1e-2d44-4e2b-9d31-2c9a1e0f2c9a",
"executionId": "8a4fdf19-49e1-4702-9cb8-31fd9c46f7e1",
"venue": "kalshi",
"marketId": "PRES-2028-DEM",
"outcomeId": "kalshi:PRES-2028-DEM:yes",
"fireMode": "client",
"status": "triggered",
"triggerEvidence": { "mark": 0.39, "samples": 2, "spread": 0.02 },
"expiresAt": "2026-11-01T00:00:00.000Z",
"occurredAt": "2026-09-05T00:05:00.000Z",
"action": "submit execution 8a4fdf19-49e1-4702-9cb8-31fd9c46f7e1 with your credentials"
}

action is present only on a client-mode conditional.triggered, and says exactly what to do next.

conditional.fire_failed has two causes, and the payload’s fireMode tells them apart: hosted means our fire loop could not turn your signed body into a venue submission: a terminal refusal (the venue rejected the order, or a credits, intent or not_submitted check refused it) settles it on the first attempt, and an ambiguous failure settles it once the attempts run out. client means you reported not_submitted yourself. Only the first is something going wrong.

CodeStatusMeaning
ROUTE_NOT_FOUND404Only while the family is switched off platform-wide (not since 2026-09-11)
VALIDATION_ERROR400A trigger, expiry, or artifact bound was not met
EXECUTION_NOT_FOUND404The execution is missing or not yours
CONDITIONAL_NOT_FOUND404The conditional order is missing or not yours
CONDITIONAL_NOT_ARMED409It already left the state this change requires
CONDITIONAL_ARMED409A live conditional still owns this execution — cancel it first
CONDITIONAL_CLOSED_NOT_SUBMITTED409You closed a conditional on this execution as not_submitted
OCO_GROUP_CLOSED409That OCO group already has a member that is no longer armed
HOSTED_FIRE_NOT_ELIGIBLE400This venue’s submit needs a credential we will not hold
HOSTED_FIRE_UNAVAILABLE503No artifact key on this deployment; client arming still works
SPEND_CAP_EXCEEDED402The stored notional does not fit your remaining rolling budget

Arming, listing, reading, cancelling, and reporting are metered at 0 credits during the beta. A hosted fire bills the standard 5-credit trade operation, refunded the same way a failed submit is. See Credits & billing.

  • Hosted firing is off. Nothing fires itself today, on any venue.
  • Hosted eligibility is Limitless alone, and it is computed, not configured. Adding a venue means changing what its submit requires, not flipping a flag.
  • Spend caps are checked at arm time against the stored notional, and again at fire. An order that fits your budget today can still be refused later.
  • The country recorded when you arm is re-applied to the venue’s geofence at fire. A hosted order armed from a region that venue restricts will not fire from it later either.