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:
- Your service sends
POST /v1/accounts/2/orders, a market buy of 0.12 lots ofXAUUSD.m. - 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.
- The
201starts 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. - Your HTTP client throws a timeout or a connection reset. From where you sit this looks identical to "the request never got there".
- Your code does the obvious thing and retries.
- 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-7f3aon any trading write:POST /v1/accounts/{id}/orders,PATCH /v1/accounts/{id}/positions/{ticket},DELETEon that position, orDELETE /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(not201) and the headerIdempotent-Replayed: true. The terminal is not touched. - Every outcome is stored: a
201fill, a422 order_rejected, a422 invalid_stops, a504 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.