# Noctant External Journal App Integration Guide

Date: 2026-06-29
Status: shareable private-beta integration guide

This guide is safe to give to another app team or coding AI so that app can
integrate with Noctant as an independent journaling/operator application.

It intentionally describes the external contract only. It does not require the
reader to understand Noctant's internal services, deployment, databases, or
admin workflows.

## Current Truth

Noctant can support a controlled private-beta journaling integration today.

To create accounts for its users, an app:

1. The journal app creates a claimable launch with stable external user and
   account ids.
2. The user opens Noctant, signs in, and consents to connect the app.
3. Noctant binds the identity and creates the simulated trading account.
4. The user trades; Noctant sends signed webhooks to the journal app.
5. The journal app reconciles through Operator API readback endpoints and
   requests a bounded replay for missed events.

For existing accounts, the person instead approves read access to your named
operator. Your server claims that grant, backfills their accounts across
companies, then follows signed trading events and reconciles through REST.
See **Reading A Person's Own Accounts**. This creates no account or membership
and spends no credit.

This is a real third-party style integration even when both apps are owned by
Noctant. The journal app must treat Noctant as an external service: API key for
REST, webhook signing secret for incoming webhooks, stable ids, and
explicit reconciliation.

Every org gets the same tools. The firm's own staff manage its operator
integrations through the console; see **Operator Setup And Credentials**.
Every fee and payout inside Noctant is simulated.

## Staging URLs

The private-beta docs surface is:

- `https://docs-staging.noctant.com/`
- `https://docs-staging.noctant.com/operator-api/self-onboarding`
- `https://docs-staging.noctant.com/operator-api/journal-app`
- `https://docs-staging.noctant.com/operator-api/partner-guide`
- `https://docs-staging.noctant.com/operator-api/changelog`
- `https://docs-staging.noctant.com/openapi/operator-api.json`

The Operator API staging base URL is:

- `https://api-staging.noctant.com`

The docs host publishes contracts only. It does not publish API keys, webhook
signing secrets, launch tokens, cookies, or partner-specific credentials.

## Integration Surface Decision

Decision date: 2026-06-29

The final Noctant operator integration model is:

```text
HTTPS Operator API + generated OpenAPI contract + optional generated SDKs
```

The HTTPS API is the real contract. Every operator must be able to integrate
with plain HTTPS requests and HTTPS webhooks without using a Noctant-owned SDK.

The OpenAPI document is the public machine-readable contract. It must be
generated from the same Rust request/response contracts that the server uses.
Do not hand-write a separate OpenAPI file and do not maintain a separate
partner-only schema by hand.

SDKs are convenience layers. They may improve developer experience, type
safety, examples, and onboarding speed, but they must stay thin and generated
from the OpenAPI contract. An SDK must not become the only supported
integration path and must not hide business rules that are absent from the
HTTPS/OpenAPI contract.

The docs portal is the human and AI handoff surface. It explains flows,
security rules, examples, retry behavior, webhook verification, and current
private-beta limitations. It does not replace OpenAPI and it must not publish
secrets.

### Why This Is The Target

- Plain HTTPS is universal. Any app, language, backend, or AI coding agent can
  integrate without waiting for a Noctant SDK.
- Generated OpenAPI prevents drift. The published spec stays tied to the same
  Rust types the server deserializes and serializes.
- Generated SDKs are useful but optional. They reduce boilerplate for common
  stacks while keeping the API contract language-neutral.
- A docs URL is necessary for true third-party work. A separate app should not
  need access to the Noctant source repo to understand the integration.
- Webhooks stay HTTPS too. Operators receive signed events at their own HTTPS
  endpoint, verify signatures, store event ids idempotently, and reconcile
  through REST readback.

### Current Private-Beta State

- Staging is configured to expose the Operator API at
  `https://api-staging.noctant.com`.
- The docs host is configured to serve generated OpenAPI at
  `https://docs-staging.noctant.com/openapi/operator-api.json`.
- Noctant's generated TypeScript SDK, `@noctant/operator-api-typescript-sdk`,
  is not published yet, and neither are the examples inside it.
- External apps can integrate today with raw HTTPS or by generating their own
  client from the OpenAPI URL.
- The docs host is configured at `https://docs-staging.noctant.com`.

## Versioning

Until the first external partner is live, `/v1` changes in place, with one
changelog entry for each change. After that, `/v1` changes only additively. A
breaking change ships as `/v2`, served beside `/v1` until every consumer has
moved.

The changelog is `https://docs-staging.noctant.com/operator-api/changelog`.
Its upgrade notes come first: every change an integration written against an
older call must make. Read them before building and before each release.

## Sources Of Truth

Use these artifacts, in this order (the SDK and its examples once they are
published):

1. `https://docs-staging.noctant.com/openapi/operator-api.json`
2. `@noctant/operator-api-typescript-sdk`
3. `@noctant/operator-api-typescript-sdk/examples/private-beta-journal-account-creator.ts`
4. `@noctant/operator-api-typescript-sdk/examples/private-beta-historical-launch.ts`

Do not hand-write request/response types from this guide. The OpenAPI document
is generated from the same Rust contracts the server uses, and the TypeScript
SDK is generated from that OpenAPI file.

Until the SDK is published, generate your own client from the OpenAPI
document, or send raw HTTPS requests that follow it exactly. The code in this
guide imports from the SDK: `client` holds the base URL and the key, and each
other import calls the operation whose `operationId` in the OpenAPI document
is its name.

For a single step-by-step private-beta handoff, use
`https://docs-staging.noctant.com/operator-api/self-onboarding`. That guide
combines the environment values, `GET /v1/me` verification, API smoke, webhook
proof, hosted launch proof, and activation checklist into one path.

## Operator Setup And Credentials

Noctant's platform staff create the firm (its first admin, feed, execution
profiles and risk terms) and set its historical replay access; an operator's
replay grants sit inside that access. The firm's `operator_admin` uses the
console (`https://admin-staging.noctant.com`; how the first one signs in:
`https://docs-staging.noctant.com/operator-api/self-onboarding`) to create an
operator, configure its webhook URL, launch origins, scopes and event
subscription, and provision or rotate its credentials. Replay grants have no
console form yet: the firm's staff set them with the staff-session door
`PATCH /v1/operators/{operator_id}/replay-product-access`. These are Firm API
staff-session operations; an `operator_support` can only read. An API key
with `api_keys:rotate` can rotate itself through `POST /v1/me/rotate-api-key`.

For each environment, obtain:

- Operator API base URL, for staging currently `https://api-staging.noctant.com`.
- One Operator API key.
- One webhook signing secret, if webhooks are enabled.
- Allowed hosted launch origins, for example `https://journal-staging.example.com`.
- Enabled Operator API scopes.
- Enabled webhook event types.
- Execution profile key for the account type, for example
  `standard-netting-100`.

Do not confuse the Operator API preset with the account execution profile.
`trading_only` grants API scopes for creating accounts; `read_only_import`
grants read scopes, webhook access and `imports:revoke` for consented imports.
The execution profile controls how a created trading account executes orders;
an import uses the account's existing terms.

A preset is a starting set of scopes: an operator keeps the scopes it holds when
a preset changes. `trading_only` holds `users:disconnect` since 2026-11-01, so an
operator set up before that does not have it until your staff add it (the
scopes `PATCH` replaces the whole list, so it sends every scope the operator
holds), and an app that requires exactly the preset's scopes at startup refuses
to start with `operator_api_scopes missing users:disconnect` until then. An app
built before that date fails the same check on an operator given the preset
after it, with `operator_api_scopes contains unexpected users:disconnect`.

Catalog version 5 makes `read_only_import` available and adds `imports:revoke`
to it and `full`. Existing operators gain no scope automatically. Your staff
must add it with `PATCH /v1/operators/{operator_id}/operator-api-scopes`,
sending the whole scope list, or apply the preset again, which sets the
operator's scopes to exactly the preset's and its webhook subscription to
`default`.

The API key authenticates calls from the journal app to Noctant.

The webhook signing secret verifies calls from Noctant to the journal app.

Do not send the webhook signing secret back to Noctant in API requests. Do not
log API keys, webhook signing secrets, launch tokens, cookies, or authorization
headers.

## Environment Variables For The Journal App

Recommended names:

```sh
NOCTANT_OPERATOR_API_BASE_URL=https://api-staging.noctant.com
NOCTANT_OPERATOR_API_KEY=...
NOCTANT_WEBHOOK_SIGNING_SECRET=...
NOCTANT_EXECUTION_PROFILE_KEY=...
NOCTANT_RETURN_URL=https://journal-staging.example.com/noctant/return
```

If the app receives webhooks:

```sh
NOCTANT_WEBHOOK_URL=https://journal-staging.example.com/webhooks/noctant/operator
```

`NOCTANT_RETURN_URL` must use an origin that Noctant has allowed for that
operator. The return URL can include a path, but the allowed origin is only the
scheme, host, and optional port.

`NOCTANT_WEBHOOK_URL` must be exact. Do not include a trailing `?`, fragment, or
unused query string. If the app is a Next.js app and the receiver lives under
an API route, a valid shape is:

```sh
NOCTANT_WEBHOOK_URL=https://journal-staging.example.com/api/webhooks/noctant/operator
```

## Authentication

Send the Operator API key as:

```http
Authorization: Bearer <NOCTANT_OPERATOR_API_KEY>
```

For every write request, send:

```http
x-noctant-request-id: <uuid>
```

The request id is for logs and audit. It is not the same thing as idempotency.
Endpoint-specific idempotency rules still apply.

Before doing anything else, call:

```http
GET /v1/me
```

The app must verify that the returned operator id, org id, environment, scopes,
webhook config, and allowed origins match the expected onboarding record.

## Request Budget And Reachability

Each operator API key draws from its operator's token bucket. The operator's
tier is `standard`, `pro`, `enterprise` or `unlimited`. The default budgets are
600, 3000 and 12000 requests a minute respectively; `unlimited` is not limited.
The default bucket holds twice the per-second rate: 20 requests on `standard`.

Answers admitted by the limiter carry:

- `X-RateLimit-Limit`: the tier's live requests-a-minute budget; read it instead
  of assuming a default.
- `X-RateLimit-Remaining`: tokens left, rounded down.
- `X-RateLimit-Reset`: seconds until the bucket is full again.

Over budget, the API answers `429` `rate_limit_exceeded` with `Retry-After`
(seconds) and `retry_after_seconds` in the body.

During a deployment window, requests to the Operator API's `/v1` paths answer
`503` `service_unavailable` with `Retry-After: 60`.

The generated OpenAPI document at
`https://docs-staging.noctant.com/openapi/operator-api.json` lists every error
code and its meaning in the `FirmErrorCode` schema; the document's
`info.description` lists each HTTP status with its codes. Switch on `error`,
never on `detail`.

Use `GET /health/live` as the reachability probe: it is unauthenticated,
returns an empty `200`, and consumes no request budget. It also answers `200`
during a deployment window, so it proves reachability, not that an API request
will be served. There is no status page. `/health/ready` is not answered from
outside.

## Stable Ids

When creating accounts, the journal app must keep stable ids on its side:

- `external_user_id`: the user's id in the journal app.
- `external_account_id`: the account/session/account-like object id in the
  journal app.
- `external_ref` (optional, historical launches only): your reference for
  what the trader replays, such as a trade or a setup; 1-64 characters from
  `A-Z`, `a-z`, `0-9`, `_` and `-`. Noctant returns it on the launch, its
  sessions, their trades and their `backtest.*` events, and shows it to no
  one.

Noctant treats those ids as opaque strings. They must not change between
retries.

`external_account_id` is the label the account is created with. Every later
sponsored-account call names it by its `program_account_id`: the account's
handle, stable across resets, carried by its Sponsored webhooks and answered by
`GET /v1/accounts/by-external-id?external_account_id=...`, the one lookup by
label.

`POST /v1/claimable-launch-sessions` stores an immutable launch/account intent
for those stable ids. If the browser claim succeeds, Noctant binds the
operator-side user to the signed-in Noctant user and materializes the account in
one transaction. The operator email/display name are UX metadata only; they do
not merge identities.

Once the account exists, launch the person into it with
`POST /v1/launch-sessions`, naming its `program_account_id`; `chart`, optional,
names the symbol and timeframe the terminal opens on. A claimable launch
whose account terms differ from the account already created under that
`external_account_id` is refused `409` `conflict` when the browser connects.

## Creating Accounts For Journal Users

Use this flow when your app provides the person's trading account.

### 1. Verify Operator Context

```ts
import {
  client,
  getOperatorContext,
} from "@noctant/operator-api-typescript-sdk";

client.setConfig({
  baseUrl: process.env.NOCTANT_OPERATOR_API_BASE_URL!,
  auth: () => process.env.NOCTANT_OPERATOR_API_KEY!,
});

const me = await getOperatorContext();
if (me.error || !me.data) {
  throw new Error(`GET /v1/me failed with ${me.response?.status}`);
}

console.log({
  operatorId: me.data.operator.operator_id,
  orgId: me.data.operator.org_id,
  environment: me.data.operator.environment,
  scopes: me.data.operator.operator_api_scopes,
  webhook: me.data.operator.webhook,
  allowedOrigins: me.data.operator.allowed_origins,
});
```

### 2. Create A Claimable Account Launch

```ts
import { createClaimableLaunchSession } from "@noctant/operator-api-typescript-sdk";

const launch = await createClaimableLaunchSession({
  headers: { "x-noctant-request-id": crypto.randomUUID() },
  body: {
    external_user_id: "journal-user-123",
    email: "trader@example.com",
    display_name: "Private Beta Trader",
    external_account_id: "journal-account-123",
    account_name: "Journal practice account",
    initial_balance: "100000.00",
    currency: "USD",
    execution_profile_key: process.env.NOCTANT_EXECUTION_PROFILE_KEY!,
    feed_source: "noctant",
    provider_type: "journal",
    rules_owner: "none",
    branding_policy: "partner_managed",
    visibility_policy: "same_sponsor_only",
    claim_allowed: true,
    marketing_allowed: false,
    requested_product_mode: "terminal",
    requested_route: "/terminal",
    chart: { symbol: "EURUSD", timeframe: "15m" },
    return_url: process.env.NOCTANT_RETURN_URL!,
  },
});

if (launch.error || !launch.data) {
  throw new Error(`launch failed with ${launch.response?.status}`);
}

const urlForBrowserRedirect = launch.data.launch_url;
```

`visibility_policy` governs only a firm's own launch page, never the person's
own signed-in profile, which lists every account of every active membership:
on a launch page, `same_sponsor_only` lists the accounts sponsored by the same
operator (integration) as the launched account, `same_sponsor_plus_noctant`
lists those plus the person's direct Noctant accounts, and `all_connected`
lists every account.

Launch URLs are one-time URLs that expire 15 minutes after you create them
(`expires_at`). Create one only when the browser is ready to redirect. Do not
store the embedded token in logs.

Set `return_url` to the page in your app the trader should come back to; its
origin must be one of your allowed origins. After a successful launch, every
Noctant page of that session in the tab the launch opened shows "Back to …"
with your name, after a reload too. A new tab the trader opens is their own
Noctant and shows none. If you opened Noctant with `window.open` and keep
`opener`, the control brings your window to the front and closes Noctant's. If
you cut the opener, it opens `return_url` in Noctant's window. Without
`return_url`, there is no control.

A launch URL opened after it expired, or opened a second time, says so and
offers "Back to …" with your name, which opens `return_url` while its origin is
still one of your allowed origins, and "Go to Sign In".

Noctant previews the launch, the user logs in or signs up, and the user
explicitly consents before the operator identity is connected to their Noctant
account. A browser already signed in as the person your operator user is
connected to, when the launch page loads, connects without the click. Real
conflicts stay blocked: Noctant never merges by email, and refuses a launch
whose operator user is already connected to a different Noctant user, or whose
signed-in Noctant user is already connected to a different user of your
operator.

A trader's Noctant sign-in lasts 30 days from its last renewal. Using Noctant
renews it at most once a day; returning before it expires in the same browser
opens Noctant without signing in. Signing out or clearing that browser's stored
data for Noctant ends it, so your journal app must not promise traders they will
never sign in again.

A trader who signs up during a launch starts with an email address Noctant has
not confirmed, and Noctant mails them a link that confirms it; the link works
for one hour. Until the address is confirmed, an alert the trader sets to send
email is refused with "Confirm your email address before sending alerts to it."
In-app and push alerts work meanwhile, and email alerts work a few minutes after
the confirmation. Tell a trader who asks to open the link in that mail or, if
it has expired or never arrived, to choose "Send me a new link" under the
refusal: Noctant mails a new one to the address they signed up with, at most one
every 5 minutes.

### 3. Read Back Account State

After the browser claim succeeds and the user trades, reconcile using REST. Do
not rely only on webhooks.

Useful endpoints, each naming the account by its `program_account_id`:

- `GET /v1/accounts/{program_account_id}/summary`
- `GET /v1/accounts/{program_account_id}/orders`
- `GET /v1/accounts/{program_account_id}/orders/{order_id}/history`
- `GET /v1/accounts/{program_account_id}/positions`
- `GET /v1/accounts/{program_account_id}/positions/{position_id}/protection`
- `GET /v1/accounts/{program_account_id}/trades`
- `GET /v1/accounts/{program_account_id}/fills`
- `GET /v1/accounts/{program_account_id}/balance-changes`

The generated SDK exposes these as:

- `getAccountSummary`
- `listOrders`
- `getOrderHistory`
- `listPositions`
- `getPositionProtection`
- `listTrades`
- `listFills`
- `listBalanceChanges`

Trades are paginated. If `next_cursor` is present, keep reading with
`before_closed_at` and `before_trade_id`.

A Sponsored trade in the trades read and the `data` of `trade.closed` state the same
closed trade, every field required, so one parser reads both: `trade_id`,
`position_id`, the entry and exit order and fill ids, `symbol`, `side`
(`long` or `short`), `quantity`, `entry_price` with `entry_time`,
`exit_price` with `closed_at`, `duration_seconds`, `gross_pnl`, `commission`
and `net_pnl` in `pnl_currency`, `is_profitable` and `close_reason`.
`entry_price` is the position's average entry price rounded to eight decimal
places, halves away from zero. `position.updated` states
`previous_average_price` and `new_average_price`, and `position.closed` its
`entry_price`, the same way. On a position built from several fills,
`entry_order_id`, `entry_fill_id` and `entry_time` name the first fill, the
one that opened the position. Compare amounts as decimals: the same amount
can carry different trailing zeros in the read and in the event.

Fills are paginated the same way, with `before_filled_at` and `before_fill_id`.
A fill states its simulated commission and what it did to positions; `order_id`
ties it to its order and `trade_id` to the trade a reduce or close made. Store
each fill under its `fill_id`, which never changes. A fill recorded later can
share the newest instant you hold, so an incremental read goes from the head
until it has passed every fill at that instant. Without `execution_scope` the
read gives the current attempt's fills, and a reset between two pages ends that
read; a sync that keeps every fill reads `all_attempts`.

Balance changes are the history of the account's simulated balance: one change
per movement, in the order the engine applied them, each labelled with the
attempt the engine recorded it in (`program_account_attempt_id`). A trade, an
order, a fill or a change the engine recorded while no challenge held the
account is listed under the account's first attempt.
`transaction_type` says why the balance moved: `deposit` is the simulated
opening deposit (change 1); `realized_pnl` a trade's realized P&L less its
exit commission; `commission` the commission of a fill that opened or added to
a position; a reversal's change holds only its opening part, and its close's
part is in the `realized_pnl`; `adjustment` a balance correction, or the
balance a reset or a new challenge sets; `payout` a simulated payout;
`payout_take` (a simulated payout taken at the request) and `payout_return`
(the same amount given back);
`challenge_scale` a change of the
capital a challenge allocates. An `adjustment` whose `event_id` no balance
correction names as its `execution_event_id` is a reset's or a new
challenge's: the first change of the attempt it opens, which sets the balance
that attempt starts on. An attempt's result is its own last change's
`new_balance`. Without `execution_scope` the read gives the current
attempt's changes; `all_attempts` gives the whole history, the opening balance
first. Store each change under its `account_id` and `sequence`, which never
change, and read on with the `after_sequence` of `next_cursor`. A change
recorded later always has a higher number, so at `all_attempts` and at one
named `attempt`, reading on never skips or repeats one. Without
`execution_scope`, each request reads the attempt current when it arrives: a
reset between two pages moves the read to the new attempt, and the ended
attempt's changes after the cursor are not returned at that scope. A sync that
keeps every change reads `all_attempts`.
A change's `source_position` equals that of its `account.balance_changed`
event. Noctant does not value open positions, so this states balance only;
equity is the balance while the account holds no open position
(`GET /v1/accounts/{program_account_id}/summary`). While a position is open, a
challenge's loss objectives (`GET /v1/challenges/{instance_id}`) still state
their `floor`: the equity at which its rule's limit is reached. A rule
that counts the estimated cost of closing open positions
(`commission_inclusive`) reaches its limit that much above `floor` while a
position is open. A step with several daily-loss or drawdown rules lists one
objective for each, and any of them can be reached first: the daily-loss
objectives first, then the drawdown objectives, each group in ascending order
of its rule's `id` in `rules_snapshot.rules`. `profit_target`
reads the realized result while a position is open, also on a step whose
`pass_measure` counts open positions.

A challenge's daily-loss pause is on the challenge, not on the account:
`GET /v1/challenges/{instance_id}` states `daily_loss_locked_until` (when it is
due to end) and `daily_loss_lock_period_start_at` (the challenge day that
earned it), and `GET /v1/challenges` states `daily_loss_locked_until`. Both are
present while the pause stands, which can be past `daily_loss_locked_until`
until the engine has closed the day. An account's `trading_status` and
`sponsorship_status` are the account's own state: while they read `active`, a
challenge's daily-loss pause, a scheduled trading window or a simulated
payout's pause can still refuse an order that opens a position. Read the first
two on the challenge and the third on the trader's payout statements
(`GET /v1/users/{global_user_id}/payout-statements`).

An order the engine rejected or cancelled states its `rejection_code` and
`rejection_reason`. `GET /v1/accounts/{program_account_id}` states the
account's execution terms, fixed for its life: `leverage`, `position_mode`,
`spread_markup_pips`, `slippage_pips` and `feed_id`.

Immediately after account creation, account-summary projection readback can be
briefly invisible. A smoke runner may retry only that exact summary `404` for a
small bounded window. Do not retry validation/auth/scope/conflict failures as
if they were projection delay.

## Reading A Person's Own Accounts

The person grants your exact operator read access to all their present and
future persistent trading accounts across source companies, closed accounts
and recorded history included, while they stay an active member of each
company with an active account profile there. Accounts your operator already
sponsors, including ended sponsorships, are excluded. The source company need
not enable imports. This grant gives no trading, account control, company
administration, KYC, payout, historical-session or personal-workspace access.

### Consent And Claim

1. Your server creates a fresh unpredictable `client_reference` and stores it
   with the signed-in journal user, initiating browser session and exact return
   URL. Use a fresh reference for each consent attempt.
2. Open `https://app-staging.noctant.com/account/imports` with URL-encoded
   `operator_id`, `client_reference` and `return_url` query parameters. The
   return origin must be allowed for that operator. The person signs in to
   Noctant without selecting or joining a company, sees the receiving company
   and app's slug, environment and operator ID, and explicitly approves.
3. Approval redirects only that browser to the approved return URL, appending
   `client_reference` and a one-time `code`. Before claiming, your callback
   verifies the reference against the pending state in that same browser
   session. Refuse a callback with no matching pending journal user/session;
   a reference alone never identifies the person to bind.
   Denial returns the reference with `status=denied` and creates no grant.
4. Your server calls `POST /v1/import-grants/claim` with the same operator's
   key, under `users:read`:

   ```json
   {
     "client_reference": "<pending reference>",
     "code": "<code from approval>",
     "return_url": "https://journal-staging.example.com/noctant/return"
   }
   ```

   Send the exact original URL from stored pending state, before callback
   parameters were added; do not take it from the current browser location or
   a callback parameter. A different URL, including another page on the same
   origin, cannot claim that approval. The first claim must be admitted before
   five minutes have elapsed since approval.
5. Store the returned grant binding and consume pending state together in your
   journal. The grant is bound once: retrying the exact claim returns its
   original receipt, including after the first-claim window or revocation; it
   never authorizes binding a second journal user. Check `revoked_at` before
   using a receipt. Never log the code or the callback URL carrying it.

Noctant returns the code only once. If the redirect is lost, the person revokes
the pending grant in **Apps reading my accounts** and your app starts a fresh
consent with a new reference. After five minutes, a fresh approval with a new
reference also replaces an expired unclaimed grant. A claimed grant is never
replaced this way.

Key rotation preserves grants because the operator is unchanged. A replacement
operator requires new consent. Claimed grants, terminal ones included, can be
polled under `users:read` with `GET /v1/import-grants/{grant_id}` or listed with
`GET /v1/import-grants` (`after_grant_id` → `next_after_grant_id`). Unclaimed
grants do not appear in those Firm reads and authorize no imports.

### Backfill Every Page

Start with `GET /v1/import-grants/{grant_id}/accounts`. Follow
`next_after_account_id` as `after_account_id` until absent, even if a page is
empty because an account lost eligibility while it was read. Each imported
account states `import_grant_id`, `source_org_id`, execution `account_id` and
`global_user_id`. It carries no sponsorship metadata or `program_account_id`.
Use `account_id` in the import routes below; keep sponsored-account reads on
their existing routes.

Every path below begins `/v1/import-grants/{grant_id}/accounts`:

| Read | Path suffix | Scope |
|---|---|---|
| Accounts / one account | empty / `/{account_id}` | `accounts:read` |
| Summary | `/{account_id}/summary` | `account_summary:read` |
| Positions / protection | `/{account_id}/positions` / `/{account_id}/positions/{position_id}/protection` | `positions:read` |
| Orders / history | `/{account_id}/orders` / `/{account_id}/orders/{order_id}/history` | `orders:read` |
| Trades / excursion | `/{account_id}/trades` / `/{account_id}/trades/{trade_id}/excursion` | `trades:read` |
| Fills | `/{account_id}/fills` | `trades:read` |
| Balance changes | `/{account_id}/balance-changes` | `account_summary:read` |

For every account, read every economic page. Pass every field of `next_cursor`
back as query parameters: orders use `before_created_at`/`before_order_id`,
trades `before_closed_at`/`before_trade_id`, fills
`before_filled_at`/`before_fill_id`, balance changes `after_sequence`, and
protection/order history `after_journal_sequence`/`after_event_ordinal`.
Upsert accounts and economic records by their stable IDs, scoped to the source
company and account. Accept optional attempt attribution as the current
OpenAPI document declares it; do not invent a program attempt.

Each account read has its own source snapshot. There is no atomic snapshot
across accounts or pages. A snapshot admitted before revocation may finish;
new reads see revocation. Reconcile rather than treating a backfill as one
instant across companies.

### Imported Events And Reconciliation

Imported signed `data` requires `org_id`, `recipient_operator_id`,
`import_grant_id`, `source_org_id`, `account_id` and `global_user_id`.
`org_id` is your receiving organization, so the usual signature and recipient
org checks still apply; `source_org_id` is the source company. The grant and
recipient operator identify the consent. Imported bodies have no `user_id`,
`external_user_id`, `program_account_id` or `external_account_id`.

Subscribe to `account.opened`, `account.closed`, `account.trading_suspended`,
`account.balance_changed`, the order and position events, `trade.closed` and
`trade.excursion`. An imported `account.opened` triggers another full account
enumeration. Pending accounts are already discoverable through REST; imports
send no `account.created`, challenge, KYC, payout or backtest events.

`operator-webhook-receiver` 0.7.0 returns an Imported body as
`Received::Unhandled` (`unreadable_data`): answer `2xx` and read it from the
OpenAPI `Imported…` schema.

Verify signed bodies, deduplicate by event ID and upsert by stable resource
IDs. Consent does not synthesize old webhook facts: backfill history through
REST. For a retained-window replay, omit `program_account_id`; providing it
selects Sponsored facts and excludes Imported facts. Follow every replay
continuation, then reconcile every REST page after gaps or a receiver upgrade.
Replay still requires the original grant and current source eligibility.

### Ending Import Access

Your app revokes with `POST /v1/import-grants/{grant_id}/revoke`, under the
explicit `imports:revoke` scope. The person can revoke in **Apps reading my
accounts**, which also lists pending and terminal grants. Either action stops
new reads and delivery admissions for that grant, leaves trading unchanged and
cannot recall data your journal already received. A send already admitted may
finish before the revoke completes. Reconnecting needs fresh consent and does
not revive the old grant's webhook facts.

There is no revoke notification. Detect person-side revocation through a `404`
on import account routes or by polling grant status. Removing the receiving company's
imports feature stops new consent, first claims, reads and facts; claimed
status, revoke, authorized replay and settlement of owed delivery remain
available. This import revoke is separate from the sponsored connection
disconnect described below.

## Webhooks

Noctant sends webhooks to the operator's configured HTTPS webhook URL.

Each delivery includes:

- `X-Noctant-Event-Id`
- `X-Noctant-Event-Type`
- `X-Noctant-Signature`

`X-Noctant-Signature` format:

```text
t=<unix_seconds>,v1=<hex-hmac-sha256>
```

The HMAC is computed over:

```text
<unix_seconds>.<raw body bytes>
```

using the operator's webhook signing secret.

The journal app must:

1. Read the raw request body bytes before JSON parsing.
2. Verify `X-Noctant-Signature`.
3. Reject stale timestamps.
4. Verify the event id and event type headers match the JSON envelope.
5. Verify `data.org_id` belongs to the expected org.
6. Deduplicate by event id only, storing it durably before/with business effects.
7. Answer `2xx` to a repeated event id, whatever its body, and keep the body
   you stored first: every send carries a new signature, and a repeat of an
   event first delivered before Noctant began sending the `data` it recorded
   (2026-10-30) can differ in its body.

This makes webhook handling safe across retries, redeploys, and receiver
restarts.

The signed envelope has `id`, `type`, `schema_version`, `created` and `data`.
The OpenAPI document publishes it for each event type under `webhooks`, keyed
by `type`, with its headers: generate your receiver's types from it.
`X-Noctant-Event-Id` and `X-Noctant-Event-Type` match `id` and `type`. An
event is well-formed when its body is a JSON object whose `id` is a UUID,
`type` a string, `schema_version` a whole number, `created` an RFC 3339 time
and `data` an object with an `org_id` UUID, and the two headers repeat `id`
and `type`. Judge only that envelope, never the rest of `data`. Answer `2xx`
to a signed, in-org, well-formed event you do not handle: a `schema_version`
or a `type` your build does not know, or `data` it cannot read (a value it
does not know, a missing field it requires, a shape it does not accept).
Store or ignore it. Refuse with `4xx` only a bad signature, another org's
event or an envelope that is not well-formed.

In `operator-webhook-receiver` 0.4.0, `WebhookVerifier::verify` returns
`Received::Handled` for an event your build reads and `Received::Unhandled`,
with its reason, for one it cannot; answer `2xx` to both. After you upgrade,
replay what your old build left unhandled, as the partner guide's
[After You Upgrade Your Receiver](private-beta-partner-onboarding-guide.md#after-you-upgrade-your-receiver)
describes.

Noctant reads your response as follows:

- `2xx`: delivered.
- Every `3xx` and every `4xx` except `429`: permanent, never retried.
- `429`, `5xx` and network failures: retried, honouring `Retry-After` on `429`
  and `503` up to 48 hours.

A lost event can be recovered through webhook replay within 30 days while
its authorization remains valid.

Noctant sends no challenge email; pass and fail reach you as the
`challenge.passed` and `challenge.failed` webhooks when your subscription
includes them.

A challenge's daily-loss rule can pause the rest of its challenge day instead
of failing the challenge. The pause changes no status: the challenge and its
account still read `active`. Until it ends, an order that would open a position
is refused; closing stays allowed. You hear its start as
`challenge.daily_loss_lockout_started`: `rule_id`, `period_start_at` (the
challenge day that earned it), `until` (when it is due to end),
`measured_loss`, `threshold` and `baseline` (amounts in the account's currency;
a limit set as a percentage arrives as the amount it came to), `timezone` and
`triggered_at`. You hear its end as
`challenge.daily_loss_lockout_cleared`: `period_start_at`, `until`,
`cleared_at` and `cause`, which is `period_boundary` (the next challenge day
began) or `attempt_ended` (another challenge took the account). Both name the
challenge by `challenge_id`, and each envelope's `created` is the engine's
`triggered_at` or `cleared_at`.

## Expected Journal Webhook Events

For accounts your app creates, the important events are account/trading events
such as:

- `account.created` (status `pending`)
- `account.opened` (status `active`: the account trades from here)
- `account.closed` (status `closed`: the account never reopens and its sponsorship has ended; the last event for the account by `created`, but for `trade.excursion`: an earlier event may still arrive after it, and all of them stay replayable while the person stays connected). `reason` is `operator` (your key closed it), `staff`, `program_ended` (its program failed on it, nobody reset it, and the template's `failed_account_end` came due: a challenge failed or expired, or a passed or completed challenge's rules were breached while its program still stood on the account), `owner_erased` (its trader's Noctant account was erased: the platform flattened the account and closed it), `connection_revoked` (its trader's connection to you ended: the platform flattened the account and closed it; **Disconnecting A Person**), or `platform`: the platform retired an account its current cell never carried, and `final_balance` is then the last balance the platform stated for it. After that, a new reset answers `410` with code `account_closed` and a new attachment `409` with code `conflict`, unless an earlier refusal answers first (`410` `person_erased` for an erased trader; `409` `conflict` when the template's reset policy refuses the reset; `404` for a person no longer connected to you); a request already accepted under its `idempotency_key` replays its recorded challenge; inside the template's window a reset keeps the same account.
- `account.balance_changed`
- `order.accepted`
- `order.modified`
- `order.rejected`
- `order.cancelled` (a working order ended unfilled because it was cancelled, or the engine refused a close it was asked to make, and no `order.accepted` comes before that one: `rejection_code` is the engine's reason code, from the same vocabulary as `order.rejected`'s `rejection_code`, e.g. `CANCELLED_BY_USER` or `OCA_CANCELLED`, and `rejection_reason` its sentence, as the orders read states them; `cancelled_by` is who cancelled it: `user` (the trader), `system` (the platform) or `operator` (your firm or its staff: a close-all, or a hold); `symbol`; `cancelled_quantity` is what ended unfilled)
- `order.expired` (a working order's time in force ran out: `time_in_force` (`gtd` or `day`), `symbol`, `expired_quantity`, and a `reason` sentence for a human)
- `order.filled`
- `position.opened`
- `position.modified` (the trader moved the stop loss or take profit of an open
  position; previous and new levels, an absent value means no level)
- `position.updated`
- `position.closed`
- `trade.closed`
- `trade.excursion` (one closed trade's best and worst price, once Noctant has stored it)
- `connection.revoked` (a person's connection to you ended: every account you
  sponsored for them is closed, their `account.closed` events are recorded
  before it (an account the engine refused before it opened sends none), and it
  is the last event from that connection, but for `trade.excursion`; Imported
  events under a separate grant continue. `connection.revoked` is never
  replayed, so if you missed it, read `GET /v1/users/{global_user_id}`;
  **Disconnecting A Person**)

`order.cancelled` and `order.expired` carry the order's symbol, and every order event carries its id: the Firm API orders read (`GET /v1/accounts/{program_account_id}/orders`) has the rest. `symbol`, `cancelled_by` and `time_in_force` are absent only on facts recorded before 2026-10-08, which a replay re-sends as they were recorded. A firm on the Default subscription receives `order.cancelled` and `order.expired` from the build that added them; an Explicit list receives them once the firm adds them.

`GET /v1/me` reports the delivered set in
`operator.webhook.subscription.event_types`, with `mode` `default` or
`explicit`. The firm's staff control the subscription in the console.

`account.balance_changed.data.source_position` is required and identifies its
canonical journal era, shard, batch sequence and event ordinal:
`{"era_namespace", "shard_id", "journal_sequence", "event_ordinal"}`, with a
non-nil `era_namespace` and a `journal_sequence` of at least 1. Compare
`(journal_sequence, event_ordinal)` within the same era/shard when
maintaining a balance snapshot; timestamps can tie and HTTP delivery can reorder.
Event ordinals are dense across a journal batch, tick-caused events included.
Positions of different eras or shards are not comparable: reconcile such a
balance from `GET /v1/accounts/{program_account_id}/summary` before you use it,
and never rewrite a balance you stored under its event id. Balance payloads
from before this field existed carry none and cannot be replayed through this
contract.

### Order modifications

`order.modified` is emitted for both trader edits and engine amendments of a
sponsored order, whether or not a challenge is active. Subscribe to it alongside
`order.accepted` and `order.filled`. Custom explicit lists must add it when
desired; a Default subscription delivers every event type.

For `order.modified`, the envelope's `created` is the engine's `modified_at`,
not delivery time. Its `data` carries `org_id`, `account_id`, `user_id`,
`global_user_id`, `external_user_id`, `program_account_id`,
`external_account_id`, `order_id`, `cause` and `modified_at`, and, when they
apply, `margin_adjustment` and the `previous_`/`new_` pairs of
`quantity`, `limit_price`, `stop_price`, `expire_at`, `stop_loss` and
`take_profit`. Amounts and prices are decimal strings. The OpenAPI document
publishes this body under `webhooks`, at `order.modified`.

Correlate a modification with the accepted order by `order_id`; modifications
do not carry `symbol`. Symbol-scoped alert rules do not match order modifications.

Optional fields are omitted when absent; unchanged parameter pairs are omitted.
For SL/TP, a present previous value and absent new value means the protection
was cleared. `cause` says who or what made the change: `trader`, or the
engine's amendment: `target_position_reduced` (the position the resting order
closes shrank: a hedging order's ticket, or a netting reduce-only order's net
position), `oca_reduced`, `child_woken` or `trail_moved`, the `cause` the
order's history states for the same change. Treat a `cause` you do not know as
a change you do not tell apart. A modification recorded before 2026-11-02
carries `reason_code` instead, absent on a trader's change, so a missing one
there cannot establish who made it.

For example, a trader's 3-to-2 edit emits `cause: "trader"`,
`previous_quantity: "3"` and `new_quantity: "2"`. The engine amends an order itself
only for strategy terms no order can carry yet (such as a one-cancels-other
group or a trailing stop), so every `order.modified` today is a trader's
edit. Store each modification as a lifecycle fact and deduplicate by envelope
`id`.

`order.modified` reports a change as it happens.
`GET /v1/accounts/{program_account_id}/orders/{order_id}/history` holds every
change to the order's terms in journal order: the order as accepted, each
change with its `cause` and each changed field's `previous` and `new`, each
change the engine checked against the order and refused, with
`rejection_code` and `rejection_reason`, and a stop-limit order's trigger. A
change refused before the engine checked it against the order, such as a
request that was too old when the engine reached it, has no entry. The webhook
and this read are written independently, so a read made as an
`order.modified` arrives may not hold that change yet; read again. Reconcile
modifications from this read, whatever order webhooks arrive in; the order's
status and fills are on `GET /v1/accounts/{program_account_id}/orders`. Page
it with `limit`; when `next_cursor` is present, pass its
`after_journal_sequence` and `after_event_ordinal` to read the next page. An
order the engine rejected at placement has no history.

### Position protection

`position.modified` reports a trader's move of an open position's stop loss or
take profit. Its `data` carries the position's `position_id`, `symbol` and
`side`, the account and user identities, `previous_stop_loss`, `new_stop_loss`,
`previous_take_profit`, `new_take_profit` and `modified_at`. Prices are decimal
strings; an absent level means the position has no level of its own. The
envelope's `created` is the engine's `modified_at`. The OpenAPI document
publishes this body under `webhooks`, at `position.modified`.

Its `source_position` orders the move against `account.balance_changed`, other
`position.modified` events and entries in the position's protection history,
within the same era and shard. It does not order it against `trade.closed`.
A firm whose subscription is an explicit list receives `position.modified`
once its list names it.

Read the timeline through
`GET /v1/accounts/{program_account_id}/positions/{position_id}/protection`.
Entries record the opening, each move's full levels and each trade exit, in
journal order, with `source_position` and `at`. Page with `limit`; when
`next_cursor` is present, pass its `after_journal_sequence` and
`after_event_ordinal` to read the next page.

### Trade excursion

`GET /v1/accounts/{program_account_id}/trades/{trade_id}/excursion` needs
`trades:read` and reads one closed trade's best and worst price, each with the
first time it was reached, from the quote standing when the position opened
through the trade's close. Prices are raw feed quotes on the side the trade's
stop-loss and take-profit are judged on: the bid for a long and the ask for a
short, before spread markup or slippage. This is the stored market record: a
quote counts even when the market was closed for trading or it did not reach
the engine.

Noctant stores each trade's answer once, after it has stored every quote
through the close: usually seconds after the close, and within about two days
when the feed has stopped. `pending` means it has not stored the answer yet;
read it again later, or wait for `trade.excursion`. `final` and `unavailable`
are stored answers and never change. `unavailable` carries its `reason`:

- `before_history_start`: the market history Noctant keeps for this instrument
  starts after this position opened.
- `market_not_served`: your firm was not served this instrument's market when
  the trade closed, or was not when Noctant stored this.
- `no_quote_before_open`: Noctant stored no quote for this instrument in the 7
  days before the position opened.

`trade.excursion` carries the same answer with the trade's `trade_id`, `symbol`,
`side`, `entry_time` and `closed_at` and the account and user identities. It is
sent once per trade of a sponsored account, including an account that has closed
since, so it can follow `account.closed`. The envelope's `created` is when
Noctant stored the answer. Nothing orders it against `trade.closed`: join them
on `trade_id`. A firm whose subscription is an explicit list receives
`trade.excursion` once its list names it. The OpenAPI document publishes this
body under `webhooks`, at `trade.excursion`.

Every trade in `GET /v1/accounts/{program_account_id}/trades` has `protection`:
`first_stop_loss` and `first_take_profit` each give the first level's `price`
and the `at` when it was set, on or before that exit; `exit_stop_loss` and
`exit_take_profit` give the levels in force at the exit. Absent means no level
of the position's own. Read exit levels there instead of folding webhooks: a
move to a level the market already crossed closes the trade at the same
instant, so delivery order cannot tell them apart.

`protection` describes the position's own levels. For an exit made by an order,
read that order's `stop_price` from `GET /v1/accounts/{program_account_id}/orders`
through the trade's `exit_order_id`. These levels and the trade's prices give
the stop distance and R in price.

## Delivery Readback And Replay

For imports, omit `program_account_id` from the replay body below, follow all
continuations and reconcile REST afterward. That replay includes only facts
still authorized by their original consent; it does not backfill history
from before consent.

For support and reconciliation, use:

```http
GET /v1/webhooks/deliveries
```

To request bounded webhook replay:

```http
POST /v1/webhooks/replays
```

Choose a past window within the retention described in **Webhooks**, then set
`fromCreatedAt`, `toCreatedAt` and a stable `replayIdempotencyKey` for it:

```ts
import { createWebhookReplay } from "@noctant/operator-api-typescript-sdk";

const replay = await createWebhookReplay({
  headers: { "x-noctant-request-id": crypto.randomUUID() },
  body: {
    idempotency_key: replayIdempotencyKey,
    program_account_id: account.program_account_id,
    event_types: ["trade.closed"],
    from_created_at: fromCreatedAt,
    to_created_at: toCreatedAt,
    limit: 100,
  },
});
```

Use the same `idempotency_key` only with the same body facts. Reusing the same
key with different facts is a caller bug and should be treated as a conflict.

A replay enqueues at most `limit` (up to 500) events per request and pins the
events committed when it starts. While the response says `has_more`, request the
next page of the same replay with
`POST /v1/webhooks/replays/{replay_request_id}/continuation` (`replay_request_id`
of the last page, a new `idempotency_key`); pages never skip or repeat an event.
`source_event_ids` lists the original event ids each page redelivers.

`to_created_at` must not be in the future. Use a past-only replay window with a
small buffer behind the current time.

## Disconnecting A Person

This section applies to the connection used to create sponsored accounts.
For read-only grants, use **Ending Import Access** above.

Either side can end a person's connection to you: your key, with
`POST /v1/users/{global_user_id}/disconnect` (scope `users:disconnect`, a body
of `{"idempotency_key": "..."}`), or the trader, with the Connections button in
Noctant. The trader's Connections button ends their connection to every operator
of your organization; your key ends only its own. Both end it the same way:

- You can no longer act for the person. You keep reading the accounts you
  sponsored, as history, and their webhooks run until the last account closes.
- A change to their accounts answers `404`, a reset or next step included;
  provisioning them, creating an account for them, a new KYC report and a
  direct launch answer `409` `connection_revoked`. A launch you minted earlier
  no longer redeems. A request you sent before under its key still answers
  what it recorded.
- A firm's staff are the org's own governance over accounts in its org, not the
  partner the connection names, and they stay until the accounts close. No
  reset or activation starts for them either.
- Every account you sponsored for them is closed: its positions close at the
  executable price, then the account closes with `account.closed` reason
  `connection_revoked`. An open challenge on it ends without a pass, a reset or
  a refund.
- A payout still under review is withdrawn. A take still under review is
  returned to the account before it closes. One you approved stays yours to
  settle with
  `PATCH /v1/users/{global_user_id}/payout-requests/{payout_request_id}/external-settlement`,
  after its account closes too.
- Once the last of those accounts has closed, Noctant records
  `connection.revoked`. Every `account.closed` of those sponsored accounts is
  recorded before it, and it is published only after them; after it, nothing
  more from that connection is recorded for you unless they connect again,
  but for `trade.excursion`, sent when its answer is stored. A disconnect does
  not end an account-import grant: Imported events about the person's other
  accounts continue under it; see [Ending Import Access](#ending-import-access).
  A backtest you launched stays readable, a session still open included, until
  the person deletes their Noctant account (below). A retried delivery can
  still arrive after it, as for every webhook.
- The answer, and `GET /v1/users/{global_user_id}`, say where the connection
  stands: `connection` is `connected`, `disconnecting` (accounts still
  closing) or `disconnected`, with `disconnected_at` from the disconnect.
- The same `idempotency_key` answers the same disconnect, even after the person
  connected again; disconnecting a person already disconnected answers that
  disconnect.
- Each disconnect records one entry in your audit trail
  (`GET /v1/audit-events`, read by a staff session, not a key):
  `operator_api.user.disconnect` by your key,
  `trader_api.connection.disconnected` by the trader. The same key sent again
  records none.

What stays and what goes:

- Your copies of the person's name and email are cleared at the disconnect.
  The names you gave their accounts become the account numbers when the
  disconnect is processed.
- Accounts, orders, positions, trades, balance changes, challenges, simulated
  payouts, KYC reports, historical sessions and your audit trail are kept with
  no time limit. A disconnect deletes none of them; the person deleting their
  Noctant account deletes their historical sessions (below).
- `connection.revoked` confirms that the connection ended and every account
  you sponsored for the person closed. It does not confirm a deletion.
- Webhook events are kept, and a replay reaches events created in the last 30
  days. While the person is disconnected, a replay sends you none of their
  Sponsored events. `GET /v1/webhooks/deliveries` lists attempts finished in
  the last 90 days.
- If the person deletes their Noctant account, their name and email are
  cleared from it and from every firm's copies, yours included. Their accounts
  close with `reason` `owner_erased`, and the accounts' records stay, naming
  the person only by id. Provisioning them again answers `410`
  `person_erased`.
  Soon after, their replay sessions on every firm, the backtests your launches
  started included, are deleted with everything they hold.
  `GET /v1/historical-sessions` then lists none of them, a read of one or of
  its trades answers `404`, and no webhook is sent for the deletion. A
  `backtest.*` event of theirs not yet delivered is not sent, and a webhook
  replay no longer reaches their `backtest.*` events. Their alerts are
  deleted too, with their delivery records, their push devices and the
  records of their alert-pause emails. Your partner-webhook deliveries and
  your audit trail stay.

The person connects to you again only through a claimable launch they accept
(**Create A Claimable Account Launch**), and only once their accounts have
closed: until then the launch answers `409` `connection_closing`. A new
connection keeps the same external id.

A disconnect does not free the external id: it stays tied to the person's
Noctant login. A different Noctant login that accepts a launch for it is
refused with `operator_identity_claimed_elsewhere`. Give a different person a
new external id.

## Bars, Prices And Times

These are the bars Noctant's chart and replay show; the Operator API serves no
bars today. A bar's `t` is its open time in epoch milliseconds, UTC. History
and replay use bid prices; replay reads M1 bid bars. Ask and mid are views the
chart composes from quotes, never sealed series. A buy fills at the ask and a
sell at the bid, each widened by the account's spread markup, and a market or
stop order also takes its slippage, while a limit fills at its limit price; so
a trade's entry or exit marker may sit off a bid bar by the spread or more.

Forex, indices, commodities and futures roll at 17:00 New York (the candle
alignment offset is −7 hours in New York local time): 21:00Z during daylight
time and 22:00Z otherwise. Crypto rolls at 00:00 UTC. Equities anchor at
09:30 New York for regular hours or 04:00 New York for extended hours.

## Historical Replay And Manual Backtest

A historical launch never creates or touches a live account; do not create an
account (`POST /v1/accounts`) for it. Use
`POST /v1/claimable-historical-launch-sessions` for a user who has not yet
connected, or `POST /v1/historical-launch-sessions` for an already-linked user.
Do not use the account-launch endpoint for `playback` or `backtest`. The
operator needs grants for the feed, time window and product mode. Without one,
both creates answer `403` `replay_grant_required`, and `detail` names the
product, the feed and the window. A `backtest` needs a `playback` grant and a
`backtest` grant.

“Replay this trade with results” is a `backtest` launch: a replay-service
session on the Replay page with practice trades, on the account its launch
hands. `account` is required for `backtest`, either
`{"kind":"account","execution_profile_key","initial_balance","currency":"USD"}`
(a USD replay account on your terms) or `{"kind":"challenge","template_id"}`
(one of your, or Noctant's public, challenges in replay, at the revision your
company is offered when you create the launch). The trader picks neither the
profile nor the size. Read results through
`GET /v1/historical-sessions`,
`GET /v1/historical-sessions/{backtest_run_id}` and
`GET /v1/historical-sessions/{backtest_run_id}/trades`. Its events are
`backtest.trade.closed` and `backtest.session.completed`; a launched
challenge's later steps and resets send theirs to the same operator.
`backtest.trade.closed` is sent when the trade closes, while the session is
open, and `backtest.session.completed` after its last trade; a trade the close
itself makes comes with it.

Each historical trade and newly recorded `backtest.trade.closed` requires
`protection` and `excursion`. Protection holds first non-null SL/TP levels
with market times and the levels at exit; absent means none. Excursion holds
`favorable_price`, `favorable_at`, `adverse_price` and `adverse_at`: best and
worst long bid or short ask during the replayed position lifetime, before
markup and slippage, including opening and closing observations. Partial
closes freeze the lifetime so far; scale-ins retain it. Ties keep the first
time. Excursion is final with no `state`. Bar times name the bar opening,
not an observed intrabar instant; the envelope's `created` is recording time.

Earlier sessions and their history were removed when analytics became
required; start a new session. Stored event bodies remain as recorded, and
replay sends nothing for removed sessions. Receiver 0.6.0 drops analytics
from new bodies; 0.7.0 exposes them and returns old close bodies as
`Received::Unhandled`, reason `unreadable_data`. Answer `2xx`, upgrade receiver
and SDK together, then replay the retained window. Replay cannot add
analytics to an old body.

Send `external_ref` on a historical launch to find its results by your own
reference: the launch, each of its sessions (a launched challenge's later
steps and resets included), their trades and their `backtest.*` events carry
it. On a claimable launch it is the one handle you hold before the trader
connects.

A `playback` launch names no `account`. It opens Chart Replay paused at
`market_time_start` and creates no session. `market_time_end` is informational
for playback, not a fence. When the launch is created and redeemed, the end is
still checked as a value: it must be after the start and inside the bound
grant's `allowed_until`, and the terminal also refuses an end that is not after
the start.

### Historical Launch Times

Send `market_time_start` and `market_time_end` as RFC 3339 strings. Any offset
is accepted and stored as UTC; send `Z`. A JSON number (epoch) is refused.

For `backtest`, the launch pins the end. The start form opens at
`market_time_start` rounded up to a whole minute (unchanged when it already is
one); the trader may move the start later, never earlier and only before the
end, and cannot move the end.

A historical launch names the chart it opens on: `chart.symbol`, an
instrument of the launch's feed (`GET /v1/feeds` lists them), and
`chart.timeframe`. A playback opens Chart Replay on it; a backtest's start form
opens on its symbol, and the trader may pick another. An equity opens on its
extended session.
The terminal is English only, and every launch opens in English.

A historical launch may carry `comparison_trade`, the trader's own trade:
its `direction`, `entry`, `exits` (in time order, none while it is open), and
an optional `stop` and `target`, every time inside the window and every price
a decimal string above 0. Noctant draws it on the launch's chart and shows
each part only once the replay reaches it. Send no size. Erasing the trader's
Noctant account removes the trades you sent.

History goes back to where the instrument's feed version starts, not to a
fixed depth, and each series states its own start:
`GET /v1/feeds/{feed_id}/availability` answers it as `history_starts` (your
grant's opening, when that is later). When Noctant starts a new version of a
feed, history starts again at that moment: no read joins two versions. A
launch whose `market_time_start` is before the instrument's one-minute history
answers `400` `no_data_for_period`, and `detail` names where it starts.

### Historical Session Lifecycle

A backtest session ends only when the trader finishes it. Reaching the end of
its data stops play; it does not finish the session. A session nobody is
watching (no open chart and no command) parks after 15 minutes, or earlier when
the service needs the room. It remains resumable with state `created`, unless
its challenge has reached its verdict. There is no abandonment event; readback
lists `created` sessions and the trades they have closed.

A launched challenge's session states its challenge's `status` as it runs.
When the challenge reaches its verdict (for example `breached`, `passed` or
`time_limit_expired`), trading stops and `challenge.status` reads the verdict
at once. A pass the firm reviews reads `pending_review`, as does a flagged
session that still trades. A challenge whose deadline comes while its verdict
is held ends there and reads `active`. The session stays `created` until the
trader finishes it, resets it or moves to the next step, and
`backtest.session.completed` is sent then, not at the verdict. A session left
after its verdict sends no event: read its verdict from readback.

A `completed` session states what it covered. It ran from `created_at` to
`completed_at`, time it spent parked included. It replayed the market from
`market_time_start` to `covered_until`: every bar that closed at or before
`covered_until` was shown. `covered_until` equals `market_time_end` when the
trader replayed the whole window; an earlier value means the session was
finished before its end. A `created` session has no `covered_until` yet.
`backtest.session.completed` states the same `created_at` and `covered_until`.

A `backtest` launch with the same `external_ref`, `feed_id`, window and
`account` (for a challenge, the same published revision) as the trader's
unfinished backtest reopens it when the trader starts with the same symbols
and start time they first started it with (for a challenge, its first step's):
no second session is made, and its results keep the `launch_session_id` of the
launch that started it. A finished session is never reopened, and neither is a
challenge whose step has passed, failed or expired: a launch that has not
started a session starts a new one, and the launch that started it answers
`409`.

The maintained example is:

```text
@noctant/operator-api-typescript-sdk/examples/private-beta-historical-launch.ts
```

Use historical replay/backtest only after the normal journaling account flow is
proven against the target app. It is intentionally more controlled because it
must never mix live account state with historical simulation state.

## Retry Rules

Retry:

- Network timeouts.
- `429`, using `Retry-After` when present.
- `503`.
- `504` `command_receipt_timeout`, and a `409` whose code is
  `journal_catching_up`, `account_command_applying`, `guards_not_acknowledged`,
  `attempt_end_applying`, `account_birth_applying`, `judgment_catching_up` or
  `browser_identity_retry_required`: the same request and key after a short
  backoff.

Keep the same external ids or idempotency key and the same body facts when
retrying an operation that supports them.

Do not blindly retry:

- `400`: request bug.
- `401`: missing or invalid API key.
- `403`: scope, operator status, origin, or grant problem, or
  `feature_not_enabled`: a feature Noctant has not ticked for your company.
- `404`: wrong id or object not visible to this operator.
- Any other `409`: idempotency or state conflict; inspect before retrying.

## Minimum Smoke Test Before Trusting The Integration

For account creation, the journal app must pass this full proof:

1. `GET /v1/me` returns the expected operator and scopes.
2. `POST /v1/claimable-launch-sessions` returns a launch URL.
3. Browser launch opens Noctant preview.
4. User signs in or signs up and explicitly consents.
5. Noctant lands in the expected route/account.
6. User places at least one test trade.
7. Journal app receives and verifies at least one signed webhook.
8. Journal app stores webhook event id idempotently.
9. Journal app reads account summary/orders/positions/trades/fills through REST.
10. Journal app reads webhook delivery ledger.
11. Journal app requests a bounded replay for one known window.
12. Same replay request with the same idempotency key is safe.
13. Same replay key with different facts returns a conflict.

For own-account import, prove this journey instead:

1. Verify the operator, scopes and webhook subscription through `GET /v1/me`.
2. Approve from a signed-in browser without an org and claim only from its
   matching pending journal session, with the exact original return URL.
3. Backfill every account and economic page, including an ordinary account
   without a program attempt and a closed account; confirm own-sponsored
   accounts are excluded.
4. Receive a signed Imported fact, verify recipient/source/grant/operator
   identity, deduplicate it, replay without `program_account_id` and reconcile.
5. Revoke from each side in separate consent runs. Confirm import reads stop,
   status remains readable and trading continues. Reconnect with fresh consent.

Do not record secrets in smoke evidence. Record only object ids, event ids,
timestamps, status codes, and sanitized request ids.

## Copy-Paste Prompt For Another Coding AI

Use this when asking another AI to integrate a journal app:

```text
Integrate this app with Noctant as a private-beta external journal operator.

Use the Noctant Operator API, not Noctant internal databases or services.
Authenticate REST calls with Authorization: Bearer NOCTANT_OPERATOR_API_KEY.
First call GET /v1/me and verify operator id, org id, scopes, webhook policy,
and allowed origins.

Choose the flow your app needs. For account creation (`trading_only`):
1. POST /v1/claimable-launch-sessions with stable external_user_id and
   external_account_id.
2. Redirect the browser to launch_url immediately.
3. Let Noctant handle login/signup, consent, identity binding, account
   materialization, and token consumption.
4. After claim, look the account up once with
   GET /v1/accounts/by-external-id?external_account_id=..., keep its
   program_account_id, and reconcile with
   GET /v1/accounts/{program_account_id}/summary, /orders, /positions, /trades
   and /fills. Launch the person into that account later with
   POST /v1/launch-sessions naming its program_account_id; a claimable
   launch for it must carry the terms first sent, for the same person.
5. Receive Noctant webhooks at the configured HTTPS webhook URL. The signed
   envelope is id, type, schema_version, created, data.
6. Verify X-Noctant-Signature over "<timestamp>.<raw body bytes>" using
   NOCTANT_WEBHOOK_SIGNING_SECRET, and that data.org_id is your org.
7. Deduplicate by X-Noctant-Event-Id in durable storage. Answer 2xx to a
   verified event your build cannot read (a type, a value or a field it does
   not know); refuse only a bad signature, another org's event or a broken
   envelope.
8. Use GET /v1/webhooks/deliveries and POST /v1/webhooks/replays for support
   and bounded backfill.
9. Read https://docs-staging.noctant.com/operator-api/changelog before building
   and before each release: its upgrade notes list every change an
   integration written against an older call must make.

For reading accounts the person already owns (`read_only_import`), follow
Reading A Person's Own Accounts in this guide. Bind a fresh reference to the
local user, browser session and exact return URL; verify that pending state
before claiming the one-time code through POST /v1/import-grants/claim.
Bind the receipt once. Page every imported account/economic read, using its
execution account_id, and handle optional attempt attribution. Imported signed
data.org_id is the recipient, source_org_id the source; grant and operator
identify consent. Replay without program_account_id, then reconcile REST.
Acquire imports:revoke explicitly; detect revocation by 404 or grant polling.
Revoke leaves trading and received copies intact. Rotation keeps grants;
operator replacement needs new consent.

Do not use POST /v1/users for normal first-time onboarding. That endpoint is
privileged direct-provision tooling and requires users:direct_provision.

Do not log approval codes, API keys, webhook signing secrets, launch tokens, cookies, or raw
Authorization headers. Do not use internal Noctant databases. Do not implement
historical replay/backtest until the normal journal account flow passes.
```

## What This Guide Does Not Yet Cover

Still private/internal:

- Public self-serve operator application and approval.
- Public package publishing for the SDK.
- Public developer portal UI.
- Production-grade support docs for arbitrary partners.
- Final public historical replay/backtest onboarding.

That is deliberate. The current goal is a true private-beta external app
experience with a narrow, testable, non-confusing contract.
