# Idempotency and trade outcomes

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

Source: https://tickwire.io/docs/idempotency/

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:

```http
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`:

| `outcome` | Meaning |
|---|---|
| `applied` | It happened. The broker confirmed it, or we found the result in the terminal. |
| `no_change` | Nothing needed doing, e.g. closing a position that's already closed. Counts as success. |
| `partial` | Part of the volume was filled. |
| `rejected` | The broker refused it. Nothing happened. |
| `unknown` | We 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](https://tickwire.io/docs/request-logs/) still shows which key sent it (`keyId`).

```bash
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.
