# Limit and recurring orders

> Place Solana limit orders through LayerHop (Jupiter Trigger) that fill when your price is reached, with partial fills, expiry, cancel and webhooks; your fee applies on every fill. Recurring (DCA) orders are not offered yet, and this page explains why. Beta.

Source: https://docs.layerhop.com/guides/limit-and-recurring-orders

A **limit order** sells a token once the market reaches your price: "sell 1 SOL as soon as it buys at least 250 USDC". LayerHop builds the order on Solana through Jupiter's Trigger program, your user's wallet signs it, and the order then waits on chain until it fills, expires or is cancelled. Each fill is recorded as an order, sends a [webhook](/guides/webhooks) and counts toward your [partner earnings](/guides/partner-fees).

Limit orders are **non-custodial**. The input sits in an order account owned by Jupiter's on-chain program until a fill; the wallet signs both the create and the cancel transaction, and LayerHop never holds the funds.

> **Beta.** Limit orders work with every `lh_test_` key today. Live mode is rolling out: until it is enabled, live requests get [`FORBIDDEN`](/errors/FORBIDDEN) ("Limit orders are not enabled for live mode yet"). Build your integration in [test mode](#test-mode) meanwhile.

## How it works

1. **Create** the order: the wallet, the token to sell and to buy, the `amount` to sell and the minimum `dstAmount` to receive for all of it. The limit price is `dstAmount / amount`. You get an order (`status: built`) and an unsigned transaction.
2. **Sign** the transaction with the wallet. Sign only, do not broadcast: `tx.submitViaApi` is `true`.
3. **Submit** the signed transaction. Jupiter lands it, the order becomes `open` and you get `limit_order.opened`.
4. **Fills** happen when the market reaches your price, in one go or in several parts. Each fill becomes an order (`quoteId` starting with `lof_`, `limitOrderId` set) and sends `limit_order.filled`.
5. The order ends as `filled`, `cancelled` (the wallet cancelled it; the unfilled part goes back) or `expired`.

## 1. Create a limit order

```bash
curl -X POST https://api.layerhop.com/v1/limit-orders \
  -H "x-api-key: $LAYERHOP_API_KEY" -H "content-type: application/json" \
  -d '{
    "wallet": "UserSolanaWallet111111111111111111111111111",
    "srcToken": "SOL",
    "dstToken": "USDC",
    "amount": "1000000000",
    "dstAmount": "250000000",
    "expiresAt": 1792540800
  }'
```

`amount` and `dstAmount` are integer strings in base units: 1 SOL is `1000000000` (9 decimals) and 250 USDC is `250000000` (6 decimals), so this order sells 1 SOL at 250 USDC or better. `expiresAt` is optional (unix seconds, at least 60 seconds ahead); without it the order stays open until it fills or is cancelled. Orders must be worth at least **$5** ([`AMOUNT_TOO_SMALL`](/errors/AMOUNT_TOO_SMALL) otherwise).

The response is the order plus the transaction to sign:

```json
{
  "id": "lo_01k7x2m4c4x8q6r0v3b5n7p9d",
  "object": "limit_order",
  "status": "built",
  "provider": "jupiter",
  "wallet": "UserSolanaWallet111111111111111111111111111",
  "srcToken": "So11111111111111111111111111111111111111112",
  "dstToken": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
  "amount": "1000000000",
  "dstAmount": "250000000",
  "fees": { "ourFeeBps": 20, "integratorFeeBps": 0, "integrator": null },
  "filledAmount": "0",
  "fillCount": 0,
  "tx": { "vm": "svm", "serializedTx": "AQAAAAAA…", "submitViaApi": true }
}
```

An API key is optional: without one the order is created on the public channel, like a quote. With a key it is attributed to your account, so you can list it without a wallet and receive its webhooks.

## 2. Sign and submit

```ts
import { LayerHop } from "@layerhop/sdk";
import { VersionedTransaction } from "@solana/web3.js";

const lh = new LayerHop({ apiKey: process.env.LAYERHOP_API_KEY });

const order = await lh.limitOrders.create({
  wallet: wallet.publicKey.toBase58(),
  srcToken: "SOL",
  dstToken: "USDC",
  amount: "1000000000",
  dstAmount: "250000000",
});

// Sign only: Jupiter lands the transaction.
const tx = VersionedTransaction.deserialize(Buffer.from(order.tx.serializedTx, "base64"));
const signed = await wallet.signTransaction(tx);
const { limitOrder, txHash } = await lh.limitOrders.submit(order.id, Buffer.from(signed.serialize()).toString("base64"));
console.log(limitOrder.status, txHash); // "open", the order's signature
```

## 3. Track fills

`GET /v1/limit-orders/{id}` returns the order with its fills; `GET /v1/limit-orders?wallet=…` lists a wallet's orders (or, with an API key and no `wallet`, your account's). Prefer the webhooks:

| Event | Sent when |
|---|---|
| `limit_order.opened` | The create transaction landed; the order is `open`. |
| `limit_order.filled` | A fill happened, partial or final. `data.fill` is the fill (an order with `amount` filled and `dstAmount` received); `data.limitOrder.status` is `filled` on the last one. |
| `limit_order.cancelled` | The wallet's cancel landed. Fills before it were already reported. |
| `limit_order.expired` | The order reached `expiresAt`. |
| `limit_order.failed` | The order transaction never showed up on chain (checked for an hour after submission). |

Each event carries `data.limitOrder`. LayerHop checks open orders every 15 seconds right after a change, backing off to every 10 minutes while nothing happens.

## 4. Cancel

```ts
const { tx } = await lh.limitOrders.cancel(order.id);
if (tx) {
  const signed = await wallet.signTransaction(VersionedTransaction.deserialize(Buffer.from(tx.serializedTx, "base64")));
  await lh.limitOrders.submit(order.id, Buffer.from(signed.serialize()).toString("base64"));
}
```

An `open` order returns a cancel transaction for its wallet: once it lands, `cancelTxHash` is set and the order becomes `cancelled` within seconds, with the unfilled input back in the wallet. An order that was built but never submitted is cancelled at once (`tx: null`).

## Fees

Your fee is the [channel fee](/concepts/fees-and-channels) plus your [partner fee](/guides/partner-fees) (`integrator`, `integratorFeeBps`, resolved exactly as for quotes). It is written into the on-chain order and charged in `dstToken` on every fill, so the user receives `dstAmount` minus the fee. Fees apply when `dstToken` is **SOL, USDC or USDT**; for other output tokens the order carries no fee and `fees` reports `0`. `fees` always shows what the order actually carries, read back from the transaction. Jupiter also charges its own Trigger fee on fills.

Each fill order carries `ourFeeBps`, `partnerFeeBps` and a `partnerFeeEstimate` in input-token units, like every other order, so fills show up in your dashboard earnings and statements.

## Test mode

With an `lh_test_` key, orders are created by a sandbox (`provider: sandbox`, `livemode: false`) with a placeholder transaction that can never land. Submit any non-empty `signedTransaction`, then drive the order:

```bash
curl -X POST https://api.layerhop.com/v1/limit-orders/lo_…/simulate \
  -H "x-api-key: $LAYERHOP_TEST_KEY" -H "content-type: application/json" \
  -d '{ "outcome": "partial_fill" }'
```

`fill` fills the rest at the limit price, `partial_fill` fills half of the rest, `expire` expires the order. Cancelling works as in live mode. The usual `limit_order.*` events go to your test endpoints within seconds.

## Recurring orders (DCA)

Recurring buys are not available through LayerHop yet. Jupiter's DCA products don't let an integrator charge a fee: the [Recurring API](https://developers.jup.ag/docs/recurring) is unmaintained and states that integrators cannot take fees, and DCA in Trigger V2 has no integrator fee either. Until a DCA provider supports a fee, schedule recurring buys yourself with [quotes and orders](/guides/same-chain) on a timer.

## Errors

| Code | When |
|---|---|
| [`FORBIDDEN`](/errors/FORBIDDEN) | Live mode is not enabled yet, or `simulate` on a live order. |
| [`UNSUPPORTED_CHAIN`](/errors/UNSUPPORTED_CHAIN) | A chain other than Solana. |
| [`AMOUNT_TOO_SMALL`](/errors/AMOUNT_TOO_SMALL) | The order is worth less than $5. |
| [`INVALID_PARAMS`](/errors/INVALID_PARAMS) | A bad wallet, amount, price or expiry, or the same token on both sides. |
| [`CONFLICT`](/errors/CONFLICT) | Nothing to submit, or cancelling an order that is not `open`. |
| [`PROVIDER_ERROR`](/errors/PROVIDER_ERROR) | Jupiter refused or failed to land the transaction. |

See the [API reference](/api-reference#create-a-limit-order) for every field.
