LayerHopDocsLaunch app (opens in a new tab)

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.

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 and counts toward your partner earnings.

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 ("Limit orders are not enabled for live mode yet"). Build your integration in 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#

Terminal
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 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#

TypeScript
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#

TypeScript
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 plus your partner fee (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:

Terminal
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 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 on a timer.

Errors#

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

See the API reference for every field.

Last updated