GuidesTrading & accounts
Conditional orders (TP/SL)
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.
Plan availability
Section titled “Plan availability”Conditional orders require Pro or above, with these per-account limits (live since 2026-09-11):
| Plan | Conditional orders armed at once |
|---|---|
| Free | Not included |
| Builder | Not included |
| Pro | Up to 25 |
| Enterprise | No 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.
Three modes, and which venue gets which
Section titled “Three modes, and which venue gets which”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 }.
| Mode | Who watches the price | Who submits when it fires | Where it works |
|---|---|---|---|
native | The venue’s own engine | The venue | Hyperliquid only |
hosted | Predictefy | Predictefy, replaying your signed body | Limitless only |
client | Predictefy | You, with your credentials | Every venue |
nativeis not a conditional order at all — it is a venue-held trigger order you sign once. On Hyperliquid, passtrigger.triggerPxandtrigger.tpslin the ordinary build body and Hyperliquid’s engine watches and fires. See the Hyperliquid build schema. Nothing on this page is involved.hostedis 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.clientis 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:
curl -s "$PREDICTEFY_EXEC_URL/v1/exec/venues" \ -H "Authorization: Bearer pk_live_YOUR_KEY" \| jq '.data[] | {venue, conditional}'The lifecycle
Section titled “The lifecycle”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.
Routes
Section titled “Routes”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.
| Route | What it does |
|---|---|
POST /v1/exec/{venue}/orders/{executionId}/arm | Attach a trigger rule to a built execution (201) |
GET /v1/exec/conditional | Your conditional orders, newest first, cursor-paged |
GET /v1/exec/conditional/{id} | One order plus its event trail |
POST /v1/exec/conditional/{id}/cancel | Cancel a still-cancellable order, erasing any artifact |
POST /v1/exec/conditional/{id}/report | Client mode: report the outcome — fired, or not_submitted |
Arming
Section titled “Arming”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.
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.
The trigger rule
Section titled “The trigger rule”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.
| Field | Default | What it means |
|---|---|---|
when | required | at_or_above or at_or_below — the direction the reference must cross |
price | required | A probability in (0, 1), on the 1e-6 grid (max six decimals) |
reference | mark | mark, bid, or ask |
confirmations | 2 | Consecutive qualifying samples before it triggers |
dwellMs | 2000 | How long the condition must hold, measured on book timestamps |
maxSpread | 0.10 | No trigger while the spread is wider than this; same 1e-6 grid |
minSize | 1 | The 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.
Expiry
Section titled “Expiry”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.
One live order per execution
Section titled “One live order per execution”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.
OCO groups
Section titled “OCO groups”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 arming
Section titled “Hosted arming”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.
The client-fire loop
Section titled “The client-fire loop”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 tofired."not_submitted"— the venue never accepted it. The order moves tofire_failed, and itsexecutionIdis left as armed. Send neitherexecutionIdnorvenueRefwith 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.
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.
While an order is armed
Section titled “While an order is armed”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.
Webhook events
Section titled “Webhook events”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.
| Event | Fires when |
|---|---|
conditional.triggered | The rule was satisfied — in client mode, your cue to submit |
conditional.fired | The order became a venue submission |
conditional.fire_failed | A hosted fire ended without a venue submission, or you reported not_submitted |
conditional.expired | expiresAt 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.
Errors
Section titled “Errors”| Code | Status | Meaning |
|---|---|---|
ROUTE_NOT_FOUND | 404 | Only while the family is switched off platform-wide (not since 2026-09-11) |
VALIDATION_ERROR | 400 | A trigger, expiry, or artifact bound was not met |
EXECUTION_NOT_FOUND | 404 | The execution is missing or not yours |
CONDITIONAL_NOT_FOUND | 404 | The conditional order is missing or not yours |
CONDITIONAL_NOT_ARMED | 409 | It already left the state this change requires |
CONDITIONAL_ARMED | 409 | A live conditional still owns this execution — cancel it first |
CONDITIONAL_CLOSED_NOT_SUBMITTED | 409 | You closed a conditional on this execution as not_submitted |
OCO_GROUP_CLOSED | 409 | That OCO group already has a member that is no longer armed |
HOSTED_FIRE_NOT_ELIGIBLE | 400 | This venue’s submit needs a credential we will not hold |
HOSTED_FIRE_UNAVAILABLE | 503 | No artifact key on this deployment; client arming still works |
SPEND_CAP_EXCEEDED | 402 | The stored notional does not fit your remaining rolling budget |
Credits
Section titled “Credits”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.
Honest limitations
Section titled “Honest limitations”- 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.