# Pre-signed orders

> The recommended order-placement flow for API trading. Sign locally and submit single orders or batches.

<Callout type="info" title="API order placement">
For API trading, use pre-signed orders. Server-side signing may slow order
ingestion.
</Callout>

Sign locally with the account owner's key and submit the completed envelope.
The request still requires PAT authentication with `orders:place_signed`.
Complete the [one-time setup](#one-time-setup), then use the
[single-order endpoint](#sending-an-order) or
[batch endpoint](#sending-many-orders-at-once).

For signed inventory and withdrawal operations, see
[account batches](/docs/api-traders/account-batches).

## When to use it [#when-to-use-it]

Use this flow for API trading at any order frequency. Your trading client
needs access to the owner's signing key, held securely in your process or
signing service. Browser trading can continue to use server-signed orders.

Pre-signed placement supports **LIMIT orders only**. For immediate execution,
use a LIMIT order with your worst acceptable price and `FAK` or `FOK` time in
force. `FAK` permits partial fills and cancels the remainder; `FOK` requires a
complete fill. The existing server-signed endpoint also supports `MARKET`
orders, but a `MARKET` request cannot be sent to a signed-order endpoint.

## One-time setup [#one-time-setup]

### 1. Export the wallet's private key [#1-export-the-wallets-private-key]

1. Open **Settings** in the web app.
2. Find the **Wallet details** card. The bottom row is labeled
   **Export private key** with a **Reveal** button.
3. Click **Reveal**. A secure modal opens — this is run by Privy on a
   separate origin, so neither the web app nor anything else on the
   page can read the key as it's displayed.
4. Copy the 0x-prefixed hex string out of the modal and close it. The
   modal can be reopened later if you lose the copy.

Store the key somewhere your bot process can read at startup — KMS,
an encrypted secret manager, an environment variable on a hardened
host. Treat it as a high-impact signing credential: **anyone who reads it can
sign on behalf of the wallet**, place signed orders, and authorize on-chain
account batches.

The key is the EOA owner of the deposit wallet, not the deposit
wallet itself. Rotating it means moving funds to a fresh wallet
through normal onboarding; there's no in-place rotation today.

### 2. Mint a PAT with the `orders:place_signed` scope [#2-mint-a-pat-with-the-ordersplace_signed-scope]

In **Settings → API Tokens**, create a new token and check the
**Place pre-signed orders** scope. Make sure the token's exchange is
set to **AGARA**.

You can grant this scope alongside the usual ones
(`orders:cancel`, `portfolio:read`, `account:stream`) — same token
covers the whole bot loop.

### 3. Note the deposit-wallet address and chain settings [#3-note-the-deposit-wallet-address-and-chain-settings]

The bot signs against the deposit wallet, not the EOA directly. Grab
the **AGARA deposit wallet** address from the same Wallet details
card — the row labeled "AGARA deposit wallet — holds funds" has a
copy button next to it.

The chain ID and standard exchange address are deployment-specific signing
values. This deployment uses chain ID `8453` and standard exchange
[`0xD06f3fA925D35077A11dB42c6614D684f10B2aD6`](https://basescan.org/address/0xD06f3fA925D35077A11dB42c6614D684f10B2aD6). Confirm both against [CTF tokens](/docs/tokens/ctf).

The signed-order validator uses this standard exchange as the verifying
contract. Do not substitute the neg-risk exchange address.

## Sending an order [#sending-an-order]

`POST /trade/v1/orders/signed`

**Scope:** `orders:place_signed`

### Request [#request]

```json
{
  "token_id": "21742633143463906290569050155826241533067272736897614950488156847949938836455",
  "side": "BUY",
  "type": "LIMIT",
  "time_in_force": "GTC",
  "price_micro": "600000",
  "shares_micro": "1000000",
  "post_only": false,
  "salt": "84629103847263918473",
  "maker": "0xCdEf012345...",
  "chain_token_id": "21742633143463906290569050155826241533067272736897614950488156847949938836455",
  "maker_amount": "600000",
  "taker_amount": "1000000",
  "side_u8": 0,
  "timestamp": "0",
  "metadata": "0x0000000000000000000000000000000000000000000000000000000000000000",
  "builder": "0x0000000000000000000000000000000000000000000000000000000000000000",
  "order_hash": "0xfe738cc74603ab47...",
  "signature": "0x4f3a8e9b..."
}
```

`chain_token_id` must encode the same unsigned integer as `token_id`. A
mismatch returns `422 validation_failed`.

The first block of fields (`token_id` through `post_only`) is identical
to the regular [Place an order](/docs/orders#place-an-order) body. The
second block is the EIP-712 envelope your bot signed over.

`side_u8`, `timestamp`, `metadata`, and `builder` are canonical
envelope values produced by `sign_limit_order` and should not be
hand-constructed. `timestamp` and `metadata` are zero placeholders
today; `builder` is unused. If you're implementing without the SDK,
mirror these values exactly.

The server recomputes the order hash from the envelope fields, asserts
it matches the `order_hash` you sent, recovers the signing address from
`signature`, and confirms it matches the wallet's owner. Any
mismatch returns `422 validation_failed`.

### What the server checks [#what-the-server-checks]

Each pre-signed order is validated before it reaches the book. Well-formed
request and envelope failures return `422` with a stable code. Malformed JSON
is the distinct `400 malformed_json` transport error.

| If…                                                                             | code                                            |
| ------------------------------------------------------------------------------- | ----------------------------------------------- |
| The request shape, envelope, signature, grid, or hash is invalid                | `validation_failed`                             |
| The order value is under the 0.10 collateral-unit minimum                       | `order_value_below_minimum`                     |
| The order value is over the 100,000 collateral-unit maximum                     | `order_value_above_maximum`                     |
| The limit price is not above 0 and below 1,000,000 micro-units                  | `order_price_out_of_range`                      |
| A GTD expiration is under 30 seconds away                                       | `order_expiration_too_soon`                     |
| The request reaches the identity check and the signed order hash already exists | `duplicate_order`                               |
| The market is not accepting orders                                              | `market_closed`                                 |
| Collateral or shares are insufficient                                           | `insufficient_balance` or `insufficient_shares` |
| A post-only order would take                                                    | `post_only_would_cross`                         |
| An immediate order cannot satisfy its instructions                              | `order_not_fillable`                            |

The response intentionally does not reveal which signature check failed. Run
client-side preflight vectors for the chain ID, exchange address, maker, hash,
low-s signature, and recovered owner. If every order fails validation, check
the chain ID and standard exchange address first. Log `request_id` for
diagnosis; do not parse `detail`.

The active values are chain ID `8453` and standard exchange
[`0xD06f3fA925D35077A11dB42c6614D684f10B2aD6`](https://basescan.org/address/0xD06f3fA925D35077A11dB42c6614D684f10B2aD6).

### Response [#response]

Identical to the regular endpoint — `202 Accepted` with the order ID:

```json
{
  "order_id": "9c4a3e7d-12b4-4f8e-9a3c-d2c7f0a45e1b",
  "source": "AGARA",
  "status": "PENDING",
  "pending_operation": "SUBMIT",
  "as_of": "2026-05-21T17:20:01.633Z"
}
```

Track the order through [`account_events`](/docs/account-stream) the same
way you would for server-signed orders.

Because you know each order's `order_hash` before you submit it, you
don't have to rely on this response to correlate what happens next:
accepted, cancelled, and fill events on the
[account stream](/docs/account-stream) carry the `order_hash`, and you can
fetch an order by it at any time with
[`GET /trade/v1/orders/by-hash/{order_hash}`](/docs/orders#look-up-an-order-by-hash).
This is the reliable way to tie acks and fills back to the right
submission when you fire many orders at once, and your recovery path if
you ever miss this response.

Submitting the same signed envelope again cannot create a second order, but
current preflight checks can return a typed failure before duplicate detection.
Look up its original state with `GET /trade/v1/orders/by-hash/{order_hash}`.
Use a fresh salt for each distinct order, but preserve the original signed
envelope when reconciling an uncertain request.

### Restrictions [#restrictions]

* **LIMIT only.** The signed-order endpoints do not accept `MARKET` requests.
  For immediate execution, use a LIMIT order with a price bound and `FAK` or
  `FOK`, as described in [When to use it](#when-to-use-it).
* **Time-in-force.** `GTC`, `GTD`, `FAK`, and `FOK` all work. A `GTD`
  order needs `expiration_unix_seconds` at least 30 seconds in the
  future, or it is rejected with `422 order_expiration_too_soon` — same rules
  as the regular endpoint's [time in force](/docs/orders#time-in-force).
* **Order size.** During beta, an order's notional
  (`price_micro × shares_micro / 1_000_000`) must be at least **0.10 collateral units**
  and at most **100,000 collateral units**. Outside that band the order is rejected with
  `422 order_value_below_minimum` or `422 order_value_above_maximum` before it
  reaches the book.
* **Price and size grid.** `price_micro` and `shares_micro` must fit the
  market's tick and lot size, or the order is rejected during placement.
  If you build orders by hand, round to the market's grid from
  [Markets](/docs/markets) first.

## Sending many orders at once [#sending-many-orders-at-once]

When you re-quote a whole ladder each tick, send the orders together
instead of one request per order.

`POST /trade/v1/orders/signed/batch`

**Scope:** `orders:place_signed`

Submit up to **32** pre-signed orders in one call. The request is a
single `orders` array, where each element is exactly the body you'd send
to `POST /trade/v1/orders/signed`:

```json
{
  "orders": [
    { "token_id": "2174263314...", "side": "BUY", "type": "LIMIT", "...": "..." },
    { "token_id": "2174263314...", "side": "SELL", "type": "LIMIT", "...": "..." }
  ]
}
```

### Response [#response-1]

Every order is checked on its own, so one bad order never sinks the
rest. You get back one result per order, in the order you sent them:

```json
{
  "results": [
    {
      "index": 0,
      "outcome": "accepted",
      "order_id": "9c4a3e7d-12b4-4f8e-9a3c-d2c7f0a45e1b",
      "source": "AGARA",
      "status": "PENDING",
      "pending_operation": "SUBMIT",
      "as_of": "2026-05-21T17:20:01.633Z"
    },
    {
      "index": 1,
      "outcome": "rejected",
      "failure": {
        "code": "duplicate_order",
        "title": "Duplicate order",
        "detail": "An order with this identity already exists.",
        "recovery": { "strategy": "none" }
      }
    }
  ],
  "as_of": "2026-05-21T17:20:01.633Z"
}
```

An `accepted` result carries the same fields as a single-order response;
track it through [`account_events`](/docs/account-stream) the same way. A
`rejected` result carries a typed `failure`. A re-submitted hash can return
`duplicate_order` when it reaches the identity check, but current market,
wallet, signature, and admission checks run first and can return another typed
failure. Look up the hash to decide whether the order exists. Walk the
`results` array and act on each outcome; don't assume all-or-nothing.

Sending an empty array or more than 32 orders is rejected outright with
`422 validation_failed` — the whole call, before any order is processed.

Batch calls are metered under their own budget, separate from
single-order placement, so a ladder re-quote can't exhaust the rate
budget your one-off placements draw from. If you exceed it you'll get a
`429` with a `Retry-After`. A received `429` means the batch was not processed;
wait for that interval before retrying it.

A lost response is not a `429`. After a timeout or disconnect, look up every
submitted `order_hash` and retry only envelopes that are still absent. Treat a
`duplicate_order` result as an existing order to reconcile, not as a newly
rejected quote.

## Implementing without the SDK [#implementing-without-the-sdk]

If you sign in a language the SDK doesn't cover, reproduce exactly what
`sign_limit_order` does. The `order_hash`/`signature` you submit are an
EIP-712 signature over the exchange's `Order` struct — get any field
wrong and the server's recompute won't match.

**Domain** — bind the signature to the exact deployment and exchange.

```json
{
  "name": "Agara CTF Exchange",
  "version": "1",
  "chainId": 8453,
  "verifyingContract": "0xD06f3fA925D35077A11dB42c6614D684f10B2aD6"
}
```

**Type** — the `Order` struct has exactly these nine fields, in this
order:

```
Order(
  uint256 salt,
  address maker,
  uint256 tokenId,
  uint256 makerAmount,
  uint256 takerAmount,
  uint8 side,
  uint256 timestamp,
  bytes32 metadata,
  bytes32 builder
)
```

**Field values:**

* `salt` — a fresh random `uint256` per order (it's the only thing that
  makes otherwise-identical orders hash differently).
* `maker` — your **deposit-wallet** address; it's both the source of
  funds and the account the signature is validated against.
* `tokenId` — the outcome's numeric token id (same value as
  `chain_token_id`).
* `side` — `0` for BUY, `1` for SELL.
* `makerAmount` / `takerAmount` — what you give / receive, where
  `collateral = shares_micro × price_micro / 1_000_000`:
  * **BUY:** `makerAmount = collateral`, `takerAmount = shares_micro`
  * **SELL:** `makerAmount = shares_micro`, `takerAmount = collateral`
* `timestamp`, `metadata`, `builder` — advisory: they're signed over but
  have no on-chain effect. Always send `0` / 32 zero bytes / 32 zero
  bytes. (They're part of the struct, so you can't omit them — the hash
  is computed over all nine fields.)

**Signature** — sign the EIP-712 digest with the deposit wallet's
**owner key**. It must be a 65-byte `r‖s‖v` ECDSA signature in canonical
low-s form (EIP-2); the deposit wallet validates it via ERC-1271 by
recovering the owner. Put the digest in `order_hash` and the signature
in `signature`, alongside the `salt`/`maker`/amounts you signed.

## Cancelling and other operations [#cancelling-and-other-operations]

Cancel, cancel-all, list, and the account stream all work the same
way with the same scopes. Only **placement** has a different endpoint.

## Security considerations [#security-considerations]

* The private key authorises orders and account batches. **Anyone who reads it
  can authorize trades or move the wallet's assets.** Store it as a high-impact
  signing credential.
* The PAT alone is not enough to forge orders — every request must
  also carry a valid signature from the wallet's owner. A leaked PAT
  is far less dangerous on this path than on the server-signed path.
* Revoking the PAT in **Settings → API Tokens** cuts off submissions
  without affecting the key itself.
* Rotating the key is out of scope here — it means moving funds to a
  new wallet through the normal onboarding flow.
