View as Markdown

Idempotency and trade outcomes

How to retry without placing a second order, and what each order result means.

When a request times out, you need to retry without placing a second order, and to know whether the first one went through.

Idempotency keys

Every mutating call accepts an Idempotency-Key header:

POST /users/current/accounts/acc_7Qx/trade
Idempotency-Key: sig-4411-acc_7Qx
X-Actor: copier
X-Reason: signal 4411
  • Same key and same body: you get the stored result. Nothing is sent to the broker again.
  • Same key and a different body: 409 idempotency_conflict. You probably reused a key by mistake.
  • On the /users/current/…/trade endpoint, clientId in the body works as the key if you don’t send the header.
  • The /users/current/… provisioning routes take an optional Idempotency-Key (existing clients don’t send one, and nothing changes for them). This covers account create, update and delete, deploy, undeploy and redeploy, credentials, increase-reliability and the enable-* routes, plus provisioning profile create, update, delete and file upload. The key belongs to the credential that sent it, as for the secret-minting calls below. A replay of a DELETE returns the stored 204, not a 404. A 4xx answer (unknown account, validation error) stores nothing, so you can fix the request and reuse the key. Broker and manager passwords are never stored: the key is bound to a hash of them, so a retry with a different password is a 409.
  • Account creation also accepts the transaction-id header, with its usual behaviour: the same id and body return the first answer (now also with Idempotent-Replayed: true), a different body is 409 ConflictError, and a retry while the first call is still running gets 202 AcceptedError with Retry-After. transaction-id and Idempotency-Key share one store, so the same value sent under either header is the same request. You can send both headers if they hold the same value. Two different values are a 400 ValidationError.
  • Calls that create or rotate a secret (POST /v1/agent-keys, POST /v1/pretrade-hooks and its rotate-secret, POST /users/current/tokens, POST /users/current/narrow-down-auth-token, POST /v1/readonly-keys and its rotate, POST /v1/sandbox/keys, and in the dashboard POST /dashboard/api-keys and its rotate) never show the secret again. A replay has the same id, with the secret field (key, secret, token or apiKey) set to null and a notice that it was already shown. A replayed rotate rotates nothing: the value from the first answer stays the working one. If you lost it, revoke (or rotate) and create a new one. narrow-down-auth-token answers {token, id}, where id is the token record you revoke with DELETE /users/current/tokens/{id}.
    • For these calls the protection is short-lived: a retry that arrives after one of our restarts or deploys may create a second credential. After retrying one of these calls, list the credentials (for example GET /v1/agent-keys) and revoke any duplicate.
    • The key belongs to the credential that sent it. Another API key, token or user of the same organisation that sends the same Idempotency-Key gets its own result, not yours.
    • If the credential was created but the call then failed, the answer is 500 with outcome: unknown and created_id, the id of the credential that now exists (never its secret). Revoke it (or rotate it) by that id, then send a new request with a new key.
  • A duplicate that arrives while the first call is still running gets 409 idempotency_in_flight with Retry-After, and does nothing.

Make keys deterministic from your own ids (signal id plus account id, say), so a crashed worker that restarts produces the same key.

A key is 1 to 255 printable characters. A longer key, or one with control characters, tabs or bidi marks, is 400 invalid_idempotency_key (keys are never truncated, so two long keys can’t collide). On the /v1 API and the account-management routes a stored result is kept for 24 hours; after that the key can be used for a new request. Each credential holds at most 10,000 stored results: past that, its least recently used results are dropped first (a call still running is never dropped).

A stored result comes back with the response header Idempotent-Replayed: true. The first call never carries it, so you can tell a replay from a fresh execution without comparing bodies. The header is on every surface that honours Idempotency-Key: the /users/current/… and terminal routes, the native /v1 API, the agent API and guarded execution.

Outcomes

Order responses carry the broker’s code (numericCode, stringCode) and our outcome:

outcomeMeaning
appliedIt happened. The broker confirmed it, or we found the result in the terminal.
no_changeNothing needed doing, e.g. closing a position that’s already closed. Counts as success.
partialPart of the volume was filled.
rejectedThe broker refused it. Nothing happened.
unknownWe can’t confirm either way. See below.

is_effective on the /v1 and terminal routes tells you whether the call changed anything, so a repeated close is distinguishable from the first one.

When the answer is “unknown”

Sometimes the broker link drops after the request left the terminal. MT5 reports this as 10031 (TRADE_RETCODE_CONNECTION) and, depending on the moment, 10011, 10012, 10028 or 10039. MT4’s equivalents are 128 (trade timeout) and 146 (trade context busy). The order may have executed.

Tickwire then checks the terminal’s positions, orders and deals for the request. If it finds the result, the outcome is applied. If it can confirm nothing happened, rejected. If it can’t tell yet, unknown.

When you get unknown:

  1. Don’t blindly retry with a new key. That’s how double positions happen.
  2. Retry with the same key after a few seconds. We re-check before doing anything.
  3. Or reconcile yourself from positions and history-deals, matching on your clientId.

The audit log

Every mutating call is recorded: time, X-Actor, X-Reason, the route, the idempotency key and the outcome. On the trading and terminal routes (/users/current/accounts/{id}/trade, /mt4/{id}/…, /mt5/{id}/…), a call without X-Actor is recorded with actor unknown. The request log still shows which key sent it (keyId).

curl -s "https://api.tickwire.io/users/current/accounts/acc_7Qx/audit?limit=50" -H "auth-token: $TW_KEY"

Account and profile changes (account.created, account.deployed, provisioning_profile.updated and so on) and narrowed tokens (token.narrowed) go to your organisation’s audit log, which the dashboard shows under Audit. Each entry has the actor (X-Actor, or api-key:<id> of the credential that called when you don’t send one), the calling credential’s key_id (always, even with an X-Actor), the reason (X-Reason, percent-decoded, at most 500 characters) and the idempotency key. Control characters (including line breaks) and bidi marks are removed from these values. Passwords and token values are never written there. A replay is not logged again.

X-Actor is a free label, but it can’t claim an identity the server assigns: a value starting with api-key:, user: or firebase: that isn’t the calling credential’s own is 400 invalid_actor, and nothing is changed.