{
  "openapi": "3.1.0",
  "info": {
    "title": "LOTUX External Signals API",
    "version": "1.0.0",
    "summary": "Send trade signals to your own MT5 / cTrader account and poll their outcome.",
    "description": "LOTUX executes YOUR signals on YOUR broker account (MT5 or cTrader) with your risk rules applied to every order. This spec covers the public External Signals webhook (used from n8n, Zapier, Python, AI agents, or Telegram bots) and the signal status poll. Human-readable guide: https://lotux.io/docs/custom . LOTUX is not a broker and provides no trading signals or investment advice. Requests are rate-limited; the JSON body is capped at 64 KB. Versioning & deprecation policy: read endpoints are URL-versioned (/v1/...); the webhook ingress contract is additive-only (fields and actions are added, never changed or removed in place). Breaking changes ship under a new versioned path, and a deprecated operation is announced at least 90 days before shutdown via Deprecation and Sunset HTTP response headers, in this spec, and at https://lotux.io/developers .",
    "contact": {
      "name": "LOTUX",
      "url": "https://lotux.io/contact",
      "email": "contact@lotux.io"
    },
    "termsOfService": "https://lotux.io/legal/terms"
  },
  "servers": [
    {
      "url": "https://api.lotux.io",
      "description": "LOTUX public API"
    }
  ],
  "paths": {
    "/webhook/custom/{accountId}": {
      "post": {
        "operationId": "sendExternalSignalToAccount",
        "summary": "Send a trade signal to one specific trading account",
        "description": "Executes the signal on the single trading account identified by accountId. The webhook secret in the body must be linked to that account (create/link secrets in the External Signals dashboard). Returns 202 when the signal is accepted for execution — execution itself is asynchronous; poll GET /v1/signals/{signalId} for the outcome.",
        "tags": [
          "signals"
        ],
        "parameters": [
          {
            "name": "accountId",
            "in": "path",
            "required": true,
            "description": "LOTUX trading-account id (shown next to the webhook URL in the External Signals dashboard).",
            "schema": {
              "type": "string"
            }
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ExternalSignal"
              },
              "examples": {
                "marketBuy": {
                  "summary": "Market buy with SL/TP",
                  "value": {
                    "secret": "YOUR_WEBHOOK_SECRET",
                    "action": "buy",
                    "symbol": "EURUSD",
                    "volume": 0.1,
                    "sl": 1.08,
                    "tp": 1.1
                  }
                },
                "taggedSell": {
                  "summary": "Sell tagged with an origin (analytics only)",
                  "value": {
                    "secret": "YOUR_WEBHOOK_SECRET",
                    "action": "sell",
                    "symbol": "XAUUSD",
                    "volume": 0.1,
                    "meta": {
                      "origin": "n8n"
                    }
                  }
                },
                "closeAll": {
                  "summary": "Close every open position",
                  "value": {
                    "secret": "YOUR_WEBHOOK_SECRET",
                    "action": "close_all"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Authenticated, but the signal routed to ZERO eligible accounts (e.g. a broadcast secret whose only targets are copy-only gift masters). Terminal — the body carries success:false, accountsResolved:0, and a correlationId but NO signalId; there is nothing to poll.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SignalNoTargets"
                }
              }
            }
          },
          "202": {
            "description": "Signal accepted for asynchronous execution. Store signalId to poll the outcome.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SignalAccepted"
                }
              }
            }
          },
          "400": {
            "description": "Malformed body or missing/invalid fields for the chosen action.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid webhook secret / API token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The target account is locked or not entitled to execute this signal.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "The account type does not accept this signal (e.g. a copy-only gift master account).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "412": {
            "description": "The connected EA/cBot version does not support this action yet — update the bridge and retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "JSON body exceeds the 64 KB cap.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded — retry later. Two limiters produce this status: the per-route limiter reports {error, limit, windowMs}; the per-account/broadcast fairness throttle reports {error, scope, message, correlationId}.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RateLimitError"
                }
              }
            }
          },
          "500": {
            "description": "Unexpected ingestion error — safe to retry (reuse the same X-Idempotency-Key where applicable).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Two distinct cases share this status — tell them apart by whether the body has a signalId. (1) PARTIAL acceptance: at least one execution region failed to enqueue, but the body is the SAME shape as the 202 — success:false with a live, pollable signalId; poll GET /v1/signals/{signalId} before resending (a retry with the same X-Idempotency-Key is deduplicated only within the 45-second replay window). (2) Not accepted at all (system backpressure, or the rate-limit store failing closed): a plain error body with NO signalId — safe to retry later.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/SignalAccepted"
                    },
                    {
                      "$ref": "#/components/schemas/ServiceUnavailable"
                    }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/webhook/custom": {
      "post": {
        "operationId": "sendExternalSignal",
        "summary": "Send a trade signal to every account linked to the secret",
        "description": "Same contract as /webhook/custom/{accountId}, but the signal fans out to EVERY trading account linked to the webhook secret. Use the per-account URL when you want to target one account.",
        "tags": [
          "signals"
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ExternalSignal"
              },
              "examples": {
                "marketBuy": {
                  "summary": "Market buy with SL/TP",
                  "value": {
                    "secret": "YOUR_WEBHOOK_SECRET",
                    "action": "buy",
                    "symbol": "EURUSD",
                    "volume": 0.1,
                    "sl": 1.08,
                    "tp": 1.1
                  }
                },
                "taggedSell": {
                  "summary": "Sell tagged with an origin (analytics only)",
                  "value": {
                    "secret": "YOUR_WEBHOOK_SECRET",
                    "action": "sell",
                    "symbol": "XAUUSD",
                    "volume": 0.1,
                    "meta": {
                      "origin": "n8n"
                    }
                  }
                },
                "closeAll": {
                  "summary": "Close every open position",
                  "value": {
                    "secret": "YOUR_WEBHOOK_SECRET",
                    "action": "close_all"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Authenticated, but the signal routed to ZERO eligible accounts (e.g. a broadcast secret whose only targets are copy-only gift masters). Terminal — the body carries success:false, accountsResolved:0, and a correlationId but NO signalId; there is nothing to poll.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SignalNoTargets"
                }
              }
            }
          },
          "202": {
            "description": "Signal accepted for asynchronous execution. Store signalId to poll the outcome.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SignalAccepted"
                }
              }
            }
          },
          "400": {
            "description": "Malformed body or missing/invalid fields for the chosen action.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid webhook secret / API token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The target account is locked or not entitled to execute this signal.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "409": {
            "description": "The account type does not accept this signal (e.g. a copy-only gift master account).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "412": {
            "description": "The connected EA/cBot version does not support this action yet — update the bridge and retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "413": {
            "description": "JSON body exceeds the 64 KB cap.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "Rate limit exceeded — retry later. Two limiters produce this status: the per-route limiter reports {error, limit, windowMs}; the per-account/broadcast fairness throttle reports {error, scope, message, correlationId}.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RateLimitError"
                }
              }
            }
          },
          "500": {
            "description": "Unexpected ingestion error — safe to retry (reuse the same X-Idempotency-Key where applicable).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "Two distinct cases share this status — tell them apart by whether the body has a signalId. (1) PARTIAL acceptance: at least one execution region failed to enqueue, but the body is the SAME shape as the 202 — success:false with a live, pollable signalId; poll GET /v1/signals/{signalId} before resending (a retry with the same X-Idempotency-Key is deduplicated only within the 45-second replay window). (2) Not accepted at all (system backpressure, or the rate-limit store failing closed): a plain error body with NO signalId — safe to retry later.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/SignalAccepted"
                    },
                    {
                      "$ref": "#/components/schemas/ServiceUnavailable"
                    }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/v1/signals/{signalId}": {
      "get": {
        "operationId": "getSignalStatus",
        "summary": "Poll the per-account outcome of a previously accepted OPENING signal",
        "description": "Reports the terminal outcome (filled / rejected) per account for an OPENING signal (market or pending entry). Close/modify/cancel results are not reported here. A handle with no recorded outcome yet returns status \"pending_or_unknown\". Authentication: a LOTUX API token (`Authorization: Bearer lx_...`), created in the dashboard; results are scoped to the accounts granted to that token.",
        "tags": [
          "signals"
        ],
        "security": [
          {
            "apiToken": []
          }
        ],
        "parameters": [
          {
            "name": "signalId",
            "in": "path",
            "required": true,
            "description": "The signalId returned in the 202 body of a webhook submit.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Outcome report (possibly still pending).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SignalStatus"
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid webhook secret / API token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "403": {
            "description": "The API token is temporarily suspended (body: error \"token_suspended\" + suspendedUntil).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "429": {
            "description": "A token-API rate cap fired: the pre-auth per-IP cap or the per-account read cap (body carries error + scope, and limit/windowMs for the read cap).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RateLimitError"
                }
              }
            }
          },
          "500": {
            "description": "Internal error while reading outcomes — safe to retry.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "apiToken": {
        "type": "http",
        "scheme": "bearer",
        "description": "LOTUX API token (starts with lx_), created in the LOTUX dashboard."
      }
    },
    "parameters": {
      "IdempotencyKey": {
        "name": "X-Idempotency-Key",
        "in": "header",
        "required": false,
        "description": "Marked optional because most requests accept it freely — but it is effectively required for breakeven, modify_sl, modify_tp, and a filtered close (a close without positionId — a request SHAPE of the close action): those are rejected with 400 when the header is missing. Retry semantics: reuse the SAME key when retrying the same action; the replay window lasts 45 seconds, so a retry within that window is deduplicated. After the window expires, a reused key is treated as a new action. A new key is always a new action.",
        "schema": {
          "type": "string",
          "maxLength": 128
        }
      }
    },
    "schemas": {
      "ExternalSignal": {
        "type": "object",
        "required": [
          "secret",
          "action"
        ],
        "additionalProperties": false,
        "properties": {
          "secret": {
            "type": "string",
            "description": "Webhook secret from the External Signals dashboard. Authenticates the signal and selects the linked account(s)."
          },
          "action": {
            "type": "string",
            "enum": [
              "buy",
              "sell",
              "buy_limit",
              "sell_limit",
              "buy_stop",
              "sell_stop",
              "buy_stop_limit",
              "sell_stop_limit",
              "close",
              "close_all",
              "partial_close",
              "cancel_pending",
              "modify_sl",
              "modify_tp",
              "modify_pending",
              "modify_pending_expiry",
              "breakeven"
            ],
            "description": "What to do. Entries: buy/sell (+ *_limit, *_stop, *_stop_limit pending variants). Management: close (positionId OR a symbol/side/pnl filter), close_all, partial_close (positionId + partialVolume), cancel_pending (orderId), modify_sl (positionId + sl), modify_tp (positionId + tp), modify_pending (orderId + one of price/stopLimitPrice/sl/tp/expiry), modify_pending_expiry (orderId + expiry), breakeven (positionId). Stop-limit entries and the modify_pending family require a recent MT5 EA and may not be available on every venue (a 412 response signals an outdated bridge)."
          },
          "symbol": {
            "type": "string",
            "description": "Instrument symbol, e.g. EURUSD, XAUUSD. Required for entries and filtered close."
          },
          "volume": {
            "type": "number",
            "exclusiveMinimum": 0,
            "maximum": 100,
            "description": "Order size in lots. Required for entries."
          },
          "price": {
            "type": "number",
            "description": "Entry price for pending orders (required for *_limit / *_stop; also used by modify_pending)."
          },
          "stopLimitPrice": {
            "type": "number",
            "description": "Trigger price for buy_stop_limit / sell_stop_limit."
          },
          "sl": {
            "type": "number",
            "description": "Stop-loss price level."
          },
          "tp": {
            "type": "number",
            "description": "Take-profit price level."
          },
          "slPips": {
            "type": "number",
            "exclusiveMinimum": 0,
            "description": "Stop-loss as a pip DISTANCE from entry (alternative to the sl price level)."
          },
          "tpPips": {
            "type": "number",
            "exclusiveMinimum": 0,
            "description": "Take-profit as a pip DISTANCE from entry (alternative to the tp price level)."
          },
          "positionId": {
            "type": "string",
            "description": "Open-position id for close / partial_close / modify_sl / modify_tp / breakeven."
          },
          "orderId": {
            "type": "string",
            "description": "Pending-order id for cancel_pending / modify_pending."
          },
          "partialVolume": {
            "type": "number",
            "exclusiveMinimum": 0,
            "description": "Lots to close for partial_close."
          },
          "expiry": {
            "type": "string",
            "description": "Pending-order expiry (modify_pending)."
          },
          "side": {
            "type": "string",
            "enum": [
              "buy",
              "sell"
            ],
            "description": "Filter for a filtered close."
          },
          "pnl": {
            "type": "string",
            "enum": [
              "profit",
              "loss"
            ],
            "description": "Filter for a filtered close."
          },
          "meta": {
            "type": "object",
            "description": "Free-form tags for YOUR analytics (e.g. {\"origin\": \"n8n\"}). Never changes routing or execution.",
            "properties": {
              "origin": {
                "type": "string",
                "description": "Where the signal came from (n8n, zapier, python-bot, ...)."
              }
            },
            "additionalProperties": true
          }
        }
      },
      "SignalNoTargets": {
        "type": "object",
        "required": [
          "success",
          "accountsResolved",
          "correlationId"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "const": false,
            "description": "Always false — no eligible target accounts."
          },
          "accountsResolved": {
            "type": "integer",
            "const": 0,
            "description": "Always 0."
          },
          "correlationId": {
            "type": "string"
          }
        }
      },
      "SignalAccepted": {
        "type": "object",
        "required": [
          "success",
          "signalId",
          "correlationId",
          "accountsResolved"
        ],
        "properties": {
          "success": {
            "type": "boolean"
          },
          "signalId": {
            "type": "string",
            "description": "Handle for GET /v1/signals/{signalId}."
          },
          "correlationId": {
            "type": "string",
            "description": "Trace id — include it when contacting support about this signal."
          },
          "accountsResolved": {
            "type": "integer",
            "description": "How many trading accounts this signal was routed to."
          }
        }
      },
      "SignalStatus": {
        "type": "object",
        "required": [
          "signalId",
          "status",
          "accounts"
        ],
        "properties": {
          "signalId": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending_or_unknown",
              "reported"
            ],
            "description": "pending_or_unknown = still in flight or unknown handle; reported = at least one account has a terminal outcome."
          },
          "reported": {
            "type": "integer",
            "description": "Number of accounts with a terminal outcome (compare with accountsResolved from the submit)."
          },
          "accounts": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "accountId",
                "outcome",
                "occurredAt"
              ],
              "properties": {
                "accountId": {
                  "type": "string"
                },
                "outcome": {
                  "type": "string",
                  "enum": [
                    "filled",
                    "rejected"
                  ]
                },
                "ticket": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "Broker ticket when filled."
                },
                "reason": {
                  "type": [
                    "string",
                    "null"
                  ],
                  "description": "Rejection reason (LOTUX code or broker/EA diagnostic)."
                },
                "occurredAt": {
                  "type": "string",
                  "format": "date-time"
                }
              }
            }
          }
        }
      },
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string"
          },
          "correlationId": {
            "type": "string",
            "description": "Present on ingestion-path errors (including fairness-throttle 429s); 400-validation and route-limiter bodies omit it (the X-Request-ID response header carries the trace id where set)."
          }
        }
      },
      "ServiceUnavailable": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "\"System under high load, retry later\" (backpressure) or \"service_unavailable\" (rate-limit store failing closed)."
          },
          "backpressure": {
            "type": "boolean",
            "description": "true on the backpressure branch."
          },
          "retryAfterSeconds": {
            "type": "integer",
            "description": "Suggested wait before retrying (fail-closed limiter; also sent as a Retry-After header)."
          }
        }
      },
      "RateLimitError": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "string",
            "description": "e.g. \"Rate limit exceeded\" (route limiter) or \"rate_limit_exceeded\" (fairness throttle / API token caps)."
          },
          "scope": {
            "type": "string",
            "description": "Which limiter fired, when reported: account_write or broadcast (webhook fairness throttle), account_read or ip (token API)."
          },
          "message": {
            "type": "string",
            "description": "Human-readable hint from the fairness throttle."
          },
          "limit": {
            "type": "integer",
            "description": "Requests allowed per window, when the limiter reports it."
          },
          "windowMs": {
            "type": "integer",
            "description": "Window size in milliseconds, when the limiter reports it."
          },
          "correlationId": {
            "type": "string",
            "description": "Trace id — present on fairness-throttle 429s, absent on the route limiter."
          }
        }
      }
    }
  },
  "tags": [
    {
      "name": "signals",
      "description": "Send trade signals to your own MT5/cTrader account and poll their outcome. When to use: any system that can send an HTTP POST (n8n, Zapier, Python, an AI agent, a Telegram bot) and needs its trades executed with user-defined risk rules."
    }
  ],
  "externalDocs": {
    "description": "External Signals guide (human-readable)",
    "url": "https://lotux.io/docs/custom"
  }
}