Idempotency keys stop a trading API filling twice

9 min readMetaKit

You will, at some point, send an order, get no answer, retry, and own two positions. Not because your code is bad. Because a network sits between you and a broker, and a network is allowed to lose the answer while keeping the question.

That sentence is the whole argument for an idempotency key on every trading write. The rest of this is the mechanics: what actually happens on the wire, what MetaKit does with the Idempotency-Key header, and the client-side retry policy that falls out once you've internalised the failure mode.

The retry that fills twice, step by step

Picture four boxes in a row: your service, the API, the MT5 terminal, and the broker's trade server. Requests travel left to right, responses right to left. Now:

  1. Your service sends POST /v1/accounts/2/orders, a market buy of 0.12 lots of XAUUSD.m.
  2. The API validates it against the symbol spec, hands it to the terminal, the terminal sends it to the broker, and the broker fills it. A position now exists. Elapsed: maybe 300 ms.
  3. The 201 starts back. Somewhere between the API and you, the connection dies. A load balancer rotating, a container being rescheduled, a laptop lid, a mobile carrier. The response never lands.
  4. Your HTTP client throws a timeout or a connection reset. From where you sit this looks identical to "the request never got there".
  5. Your code does the obvious thing and retries.
  6. Steps 1 and 2 run again. You now hold 0.24 lots with two stop losses, and your risk model thinks it's 0.12.

Every step in that list is normal. There is no bug in the sequence, only an ambiguity: from the client's chair, "the request was lost" and "the response was lost" are indistinguishable, and they require opposite actions. That ambiguity is permanent. A longer timeout only moves where the cut can happen. Better infrastructure makes it rarer, never impossible. The only fix is to make the retry harmless.

The timing is also against you. An execution path is at its slowest when markets are moving, which is exactly when retries are most tempting and a doubled position hurts most.

What a good idempotency key is

A key names the intent, not the attempt. It should be the identifier your system already has for "this decision to trade": the signal id, the row id in your orders table, the alert id from the strategy. Something that exists before the first HTTP request is built and survives a process restart.

The classic mistake is generating a fresh UUID inside the function that sends the request. That key is unique per attempt, so it protects against nothing: the retry has a new key and walks straight through to the broker. A random UUID is fine only if it's minted once, stored against the intent, and reused on every retry. If you're storing it anyway, you may as well use the id you already had.

A shape that works: signal-7f3a for the entry, signal-7f3a-sl-1 for the first stop move, signal-7f3a-close-half for the partial close. Each operation on the same intent gets its own key. Each attempt of the same operation reuses it. Keys are scoped to account and operation on our side, so the same string on two accounts is two keys; naming them distinctly anyway keeps your logs readable when something goes wrong at 2 a.m.

What MetaKit does with the header

The exact behaviour, so you can build on it rather than guess at it:

  • Send Idempotency-Key: signal-7f3a on any trading write: POST /v1/accounts/{id}/orders, PATCH /v1/accounts/{id}/positions/{ticket}, DELETE on that position, or DELETE /v1/accounts/{id}/orders/{ticket}.
  • The first request with that key executes normally and its full outcome is stored for 24 hours, scoped to the account and the operation.
  • A repeat within 24 hours returns the stored body with HTTP 200 (not 201) and the header Idempotent-Replayed: true. The terminal is not touched.
  • Every outcome is stored: a 201 fill, a 422 order_rejected, a 422 invalid_stops, a 504 terminal_timeout. Replay returns whatever the first attempt produced, failures included.
  • If the first request is still executing when the duplicate arrives you get 409 idempotency_in_progress. Wait briefly and resend the same key.

The "every outcome" rule has a consequence that surprises people: a rejected order stays rejected under that key. If the broker said 10019 not enough money and you fix it by halving the volume, the corrected order needs a new key (signal-7f3a-2, say), or you get the old rejection back for a day. The key is the identity of the request, not a checksum of its body; the stored response is what comes back, whatever you attach the second time. So never reuse a key for a different order.

The retry policy that falls out of it

Once replay is guaranteed, the policy is short enough to hold in your head.

Retry with the same key freely. A connection error, a client-side timeout, a 409 idempotency_in_progress: resend with the same key and a little backoff. The worst case is a replay of what already happened, which is precisely what you wanted to find out.

Never generate a new key after a 504 terminal_timeout until you've reconciled. The 504 means the broker didn't answer within the 10 second cap. It does not mean the order didn't fill. Replaying the key replays the 504, which is honest but tells you nothing about state. So: GET /v1/accounts/2/positions, look for a position matching your symbol, side and volume that appeared after you sent, or wait for the position.opened webhook. Only when it isn't there do you mint signal-7f3a-2 and send again, and even then, bound it. Two timeouts in a row means the terminal or the broker is unwell, and no key will fix that. Check the account's status and alert a human.

Treat a 422 as final for that key: fix the request, new key. Treat 403, 409 account_not_connected and 502 upstream_error as "stop sending orders and fix the account", not as things to retry into.

That's the entire policy. The place-orders post shows it inline in a full Python sequence; the TypeScript below packages it.

Webhooks arrive on their own clock

If you also consume trade webhooks, expect position.opened to land before or after your HTTP response, and to see both orderings in the same afternoon. The webhook fires from the terminal the moment the position exists; the HTTP response has to travel back through the API to you. Under normal conditions the response wins. Under exactly the conditions that produce a timeout, the webhook wins, and may be the only notification you ever get for that fill.

So reconciliation after a 504 has two sources: /positions and your webhook inbox. Key both to the position ticket from data.ticket, and match the position to your intent by symbol, side, volume and time. Don't try to make the webhook handler and the HTTP handler agree on who "owns" the position; let both upsert the same record. Webhook deliveries retry up to five times with no delivery id, so your handler already has to be idempotent on data.ticket; the webhook delivery post covers that side of the problem, including why position.closed can arrive before position.opened.

If you know Stripe's version, it is the same idea

Stripe made this pattern familiar for payments. You send an Idempotency-Key, they store the first result for 24 hours, and a repeat gets the saved response, errors included. A repeat while the first is still in flight is refused. Their docs suggest random V4 UUIDs but are explicit that the key must be reused across retries of the same operation, which is the same point as above: the randomness is fine, the per-attempt regeneration is not.

MetaKit's differences are small and deliberate. A replay is marked with Idempotent-Replayed: true and a 200, so you can log "nothing new happened" separately from a real 201. The scope is per account and operation rather than per API key. And the thing being protected is a broker fill rather than a card charge, which is worse to duplicate: a double charge is refundable, a double position at a moving price is not.

A fetch wrapper with bounded retry

Native fetch, no dependencies. The key is a parameter, on purpose: the caller decides it before building the body.

const BASE = "https://api.metakit.cloud/v1";
 
export type OrderRequest = {
  symbol: string;
  side: "buy" | "sell";
  type?: "market" | "limit" | "stop";
  volume: number;
  price?: number;
  sl?: number;
  tp?: number;
  deviation?: number;
  comment?: string;
  expiration?: string;
};
 
export type OrderResponse = {
  status: "filled" | "placed";
  order_ticket: number;
  position_ticket: number | null;
  deal_ticket: number | null;
  fill_price: number | null;
  volume: number;
  sl: number | null;
  tp: number | null;
  retcode: number;
  retcode_message: string;
  time: string;
};
 
export type OrderOutcome =
  | { kind: "ok"; replayed: boolean; order: OrderResponse }
  | { kind: "rejected"; code: string; message: string; retcode?: number; retcode_message?: string }
  | { kind: "timeout" } // 504 or retries exhausted: reconcile against /positions before minting a new key
  | { kind: "failed"; status: number; code: string; message: string };
 
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
 
export async function placeOrder(
  apiKey: string,
  accountId: number,
  idempotencyKey: string, // your signal id: decided before this call, identical on every retry
  body: OrderRequest,
  maxAttempts = 4,
): Promise<OrderOutcome> {
  for (let attempt = 1; ; attempt++) {
    const backoff = 500 * 2 ** (attempt - 1);
    let res: Response;
    try {
      res = await fetch(`${BASE}/accounts/${accountId}/orders`, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${apiKey}`,
          "Content-Type": "application/json",
          "Idempotency-Key": idempotencyKey,
        },
        body: JSON.stringify(body),
        signal: AbortSignal.timeout(15_000), // the server caps at 10 s; leave room for the 504 to arrive
      });
    } catch {
      // Lost request or lost response: indistinguishable. Same key, so resending is safe.
      if (attempt >= maxAttempts) return { kind: "timeout" };
      await sleep(backoff);
      continue;
    }
 
    if (res.status === 201 || res.status === 200) {
      return {
        kind: "ok",
        replayed: res.headers.get("Idempotent-Replayed") === "true",
        order: (await res.json()) as OrderResponse,
      };
    }
 
    const payload = await res.json();
    const code: string = payload?.error?.code ?? "unknown";
    const message: string = payload?.error?.message ?? res.statusText;
 
    if (res.status === 409 && code === "idempotency_in_progress") {
      if (attempt >= maxAttempts) return { kind: "timeout" };
      await sleep(backoff);
      continue;
    }
    if (res.status === 504) return { kind: "timeout" }; // replaying this key returns the 504 again
    if (res.status === 422) {
      return { kind: "rejected", code, message, retcode: payload.retcode, retcode_message: payload.retcode_message };
    }
    return { kind: "failed", status: res.status, code, message };
  }
}

Usage is one line, and the important part is what the caller passes in:

const outcome = await placeOrder(process.env.METAKIT_KEY!, 2, signal.id, {
  symbol: "XAUUSD.m", side: "buy", volume: 0.12, sl: 2310.5, tp: 2362, comment: `signal ${signal.id}`,
});
if (outcome.kind === "timeout") {
  // reconcile: GET /positions or wait for position.opened, then decide whether a new key is warranted
}

signal.id existed before this function was called. If the key you're passing is generated inside the function that sends, it's the wrong key. The header contract is under "Trading" in llms.txt and at app.metakit.cloud/docs.