# Noctant Operator API Private-Beta Partner Guide

Date: 2026-06-24
Status: private-beta onboarding reference

Audience: a trusted private-beta operator. It is served on the private-beta
docs host, at `https://docs-staging.noctant.com/operator-api/partner-guide`.

For a shareable handoff that another app team or coding AI can use without
reading Noctant internals, start with the
[journal-app guide](2026-06-29-external-journal-app-integration-guide.md),
served at `https://docs-staging.noctant.com/operator-api/journal-app`.
That guide is the canonical private-beta external journal integration contract;
this document remains the fuller partner reference.

The source of truth for request and response shapes is the generated OpenAPI
document, served at `https://docs-staging.noctant.com/openapi/operator-api.json`.
The TypeScript SDK is generated from that same OpenAPI artifact. Do not build
against copied request shapes from this guide.

The durable integration contract is HTTPS Operator API plus generated OpenAPI.
The SDK is a thin generated convenience layer, not the source of business
truth and not the only supported integration path. It is not published yet,
and neither are the examples below, which ship inside it: generate your own
client from the OpenAPI document, or call the API over HTTPS as it describes.

The maintained SDK example for this guide is
`@noctant/operator-api-typescript-sdk/examples/private-beta-journal-account-creator.ts`
and is included in the SDK package typecheck.

The maintained historical replay/backtest example is
`@noctant/operator-api-typescript-sdk/examples/private-beta-historical-launch.ts`.
Use that example only when the firm's staff have enabled historical product
grants for the operator.

## What Each Environment Needs

For each private-beta environment, arrange:

- Operator API base URL.
- One Operator API key.
- One webhook signing secret, if webhooks are enabled.
- Allowed hosted-launch return origins.
- Enabled scopes and webhook event types.
- A client generated from the current OpenAPI document.

See the journal-app guide's
[Operator Setup And Credentials](2026-06-29-external-journal-app-integration-guide.md#operator-setup-and-credentials)
for the firm's console setup and key rotation.

The API key authenticates REST calls. The webhook signing secret verifies
Noctant-to-operator webhook deliveries. Do not send the webhook signing secret
to the Operator API.

## SDK Setup

```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 meResult = await getOperatorContext();
if (meResult.error) {
  throw new Error(`GET /v1/me failed with ${meResult.response?.status}`);
}

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

The first production check is always `GET /v1/me`. Confirm the returned
operator id, org id, environment, scopes, webhook policy, and allowed origins
before creating users or accounts.

## Request IDs And Idempotency

Send `x-noctant-request-id` on write requests. It must be a UUID generated by
the operator for that outbound attempt or business action. It is for audit and
log correlation; it does not make a request idempotent by itself.

```ts
const requestHeaders = {
  'x-noctant-request-id': crypto.randomUUID(),
};
```

Idempotency rules are endpoint-specific:

- `POST /v1/claimable-launch-sessions` stores a short-lived immutable
  account-launch intent for a stable `external_user_id` and
  `external_account_id`. The permanent user/account/session records are created
  only after Noctant browser login and consent.
- Endpoints with an `idempotency_key` body field require the same key and same
  body facts on retry; reusing the key with different facts returns `409`.
- `POST /v1/challenges` and `POST /v1/challenges/{instance_id}/reset` replay
  the challenge recorded for the same request and key even after its sponsored
  account closed. On a closed account a new attachment answers `409`
  `conflict` and a new reset `410` `account_closed`, 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); neither starts a run or reopens the account. The account must still be one your operator sponsors
  for that trader, with Noctant enforcing its rules.
- Hosted launch sessions are short-lived one-time redirects. Create a session
  only when the browser is ready to redirect, and record the returned
  `launch_session_id` for support.

## Money Is A Decimal String

Every amount, price, quantity and percent the Firm API carries is a decimal
string, in requests and in answers: `"100000"`, `"0.10"`, `"-12.5"`. A JSON
number is refused, because a float cannot carry `0.1` exactly, and so is an
exponent (`"1e5"`): the answer is `400 validation_failed` with one sentence
naming the field, e.g. `initial_balance must be a decimal string such as
"0.10"; got 100000.5.` The string keeps its scale (`"0.10"` comes back as
`"0.10"`). The OpenAPI document publishes each one as a string with the pattern
`^-?\d+(\.\d+)?$`, and the TypeScript SDK types them as `string`.

## Journal/Account-Creator Flow

This example is the recommended first-beta lane. Values such as
`execution_profile_key`, route, return URL, branding, and visibility are
assigned during onboarding.

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

```ts
import {
  createClaimableLaunchSession,
  getAccountByExternalId,
  getAccountSummary,
  listOrders,
  listPositions,
  listTrades,
} from '@noctant/operator-api-typescript-sdk';

async function requireData<T>(
  result: { data?: T; error?: unknown; response?: Response },
  label: string,
): Promise<T> {
  if (result.error || !result.data) {
    throw new Error(`${label} failed with ${result.response?.status ?? 'no response'}`);
  }
  return result.data;
}

const externalUserId = 'partner-user-123';
const externalAccountId = 'partner-account-123';

const launch = await requireData(
  await createClaimableLaunchSession({
    headers: { 'x-noctant-request-id': crypto.randomUUID() },
    body: {
      external_user_id: externalUserId,
      email: 'trader@example.com',
      display_name: 'Private Beta Trader',
      external_account_id: externalAccountId,
      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',
      return_url: 'https://partner.example.com/noctant/return',
    },
  }),
  'create claimable launch session',
);

const claimableLaunchSessionId =
  launch.claimable_launch_session.claimable_launch_session_id;
const launchUrl = launch.launch_url;
```

Redirect the browser to `launchUrl` immediately, and whole: the one-time
token is its fragment (`.../launch#nlt_...`), which a browser sends to no
server, so a redirect that drops or encodes the `#` produces a link Noctant
refuses. Noctant handles login/signup, explicit consent, identity binding,
account materialization, and token consumption. After the user trades, read back the account state through
the same generated SDK. Every account call names the account by its
`program_account_id`, the handle every account webhook carries; the one lookup
by your own label answers it:

```ts
const account = await requireData(
  await getAccountByExternalId({
    query: { external_account_id: externalAccountId },
  }),
  'look up the account',
);
const path = { program_account_id: account.account.program_account_id };

const summary = await getAccountSummary({ path });

const orders = await listOrders({ path, query: { limit: 100 } });

const positions = await listPositions({ path });

const trades = await listTrades({ path, query: { limit: 100 } });

for (const result of [summary, orders, positions, trades]) {
  if (result.error) {
    throw new Error(`readback failed with ${result.response?.status}`);
  }
}
```

List endpoints return `next_cursor` when more rows exist. For trades, pass
`before_closed_at` and `before_trade_id` from the previous response cursor:

```ts
let page = await listTrades({ path, query: { limit: 100 } });

while (page.data?.next_cursor) {
  const cursor = page.data.next_cursor;
  page = await listTrades({
    path,
    query: {
      limit: 100,
      before_closed_at: cursor.before_closed_at,
      before_trade_id: cursor.before_trade_id,
    },
  });
}
```

## Feeds And Instruments

With `feeds:read`, `GET /v1/feeds` lists the feeds the firm lists instruments on or this key may replay, the key's
effective playback and backtest windows, and each instrument's tick size, pip size and contract size as decimal
strings, all as of `as_of`.

`GET /v1/feeds/{feed_id}/availability` answers for a symbol, timeframe and mode, with `session_scope` for equities:
where the key's history starts, its sealed-bar coverage inside the effective window and requested bounds, and bar
gaps and feed-clock gaps, each paged independently with `next_before`. A launch is still authorized at launch time.

## Historical Replay/Backtest Flow

A backtest launch hands a USD replay account on your terms, or one of your (or
Noctant's public) challenges in replay; it never creates or touches a live
account. Do not create a sponsored live account for it and do not use
`POST /v1/launch-sessions` for `playback` or `backtest`.

Use `POST /v1/historical-launch-sessions` only after the firm's staff have granted
the operator access to the exact historical feed/product/window. Name the launch's
`chart` with a symbol `GET /v1/feeds` lists for that feed;
`GET /v1/feeds/{feed_id}/availability` tells you whether the window holds its
history. The returned launch URL is a short-lived one-time hosted browser launch. The launch's `account`
(required for `backtest`, refused for `playback`) is what its backtest session
runs: `{"kind":"account", "execution_profile_key", "initial_balance",
"currency": "USD"}`, checked as `POST /v1/accounts` checks those terms, or
`{"kind":"challenge", "template_id"}`, pinned to the revision your company is
offered when you create the launch. A launched challenge's later steps and
resets belong to the same launch: each is its own historical session, and its
webhooks reach the operator that made the launch. For product behavior and
time bounds, see the journal-app guide's
[Historical Replay And Manual Backtest](2026-06-29-external-journal-app-integration-guide.md#historical-replay-and-manual-backtest)
and [Historical Launch Times](2026-06-29-external-journal-app-integration-guide.md#historical-launch-times).

The launch's product policies are rechecked when the trader redeems it: a
policy revoked or narrowed after minting refuses the launch.

Reconcile through `GET /v1/historical-sessions` or
`GET /v1/historical-sessions/{backtest_run_id}`, and
`GET /v1/historical-sessions/{backtest_run_id}/trades`, which lists a
session's trades as they close; after `backtest.session.completed` it lists
all of them.
Every historical session is a backtest run (`session_mode = backtest`), states
its `account_currency`, and, for a challenge, its `challenge` (`chain_id`,
`step_sequence`, `reset_number`, `status`).

The session response also includes persisted lifecycle `state`. Today that
state is closed to `created` or `completed`; live playback states such as
running or paused are not historical readback states.
See [Historical Session Lifecycle](2026-06-29-external-journal-app-integration-guide.md#historical-session-lifecycle)
for completion and resumption.

Backtest webhooks use separate event names such as `backtest.trade.closed` and
`backtest.session.completed`. They are historical events that name no live
account, and cannot be filtered by `program_account_id` during webhook replay.
For bounded redelivery, call `POST /v1/webhooks/replays` with `event_types` set
to the needed `backtest.*` events and omit `program_account_id`; then reconcile
attempts through `GET /v1/webhooks/deliveries`.

## Webhooks

Webhook deliveries include:

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

The OpenAPI document publishes these headers and each event type's signed body
under `webhooks`, keyed by the event's `type`.

`X-Noctant-Signature` has the format
`t=<unix_seconds>,v1=<hex-hmac-sha256>`. The HMAC is computed over
`<unix_seconds>.<raw body bytes>` using the operator's webhook signing secret.
Verify the byte-exact raw body before trusting the JSON.

Verify the signature and timestamp before judging the event's org, headers, or
envelope. A receiver serves the org `GET /v1/me` names for its key and
deduplicates by event id only. Answer `2xx` to a signed, in-org,
[well-formed](2026-06-29-external-journal-app-integration-guide.md#webhooks)
event you do not handle (a type, a value or a field your build does not know);
store or ignore it. Refusals stay `4xx` and answer with only their category
(`signature`, `scope` or `malformed`); an unsigned request learns only
`signature`.

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. Its error is only a
refusal. `operator-webhook-receiver-postgres`'s
`PostgresWebhookIdempotencyStore::verify_and_process` verifies, dedupes
durably and runs your processor on a handled event. It hands an unhandled
event back to you without running the processor or recording it: answer `200`
and log its reason, then follow
[After You Upgrade Your Receiver](#after-you-upgrade-your-receiver) once your
build reads it. See the journal-app
guide's [Webhooks](2026-06-29-external-journal-app-integration-guide.md#webhooks)
for the signed fields, subscription readback, delivery outcomes, and recovery.

Order lifecycle subscriptions include `order.modified` for trader edits and
engine amendments. A Default subscription delivers it, as it delivers every
event type; an Explicit list must name it. Each modification states its
`cause`: `trader`, or the engine's amendment (for example,
`target_position_reduced`), alongside previous/new values. Correlate modifications
with the accepted order by `order_id`; the modification has no `symbol`. See the
[order modification contract](2026-06-29-external-journal-app-integration-guide.md#order-modifications)
for its payload fields and historical-record semantics.

## Delivery Readback And Replay

Use delivery readback for support and reconciliation:

```ts
import {
  continueWebhookReplay,
  createWebhookReplay,
  listWebhookDeliveries,
} from '@noctant/operator-api-typescript-sdk';

const deliveries = await listWebhookDeliveries({
  query: {
    event_type: 'trade.closed',
    status: 'delivered',
    limit: 50,
  },
});

if (deliveries.error) {
  throw new Error(`delivery readback failed with ${deliveries.response?.status}`);
}
```

Request bounded replay only for a known time window and, when possible, a known
account. Supply that window as `fromCreatedAt` and `toCreatedAt`:

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

if (replay.error) {
  throw new Error(`webhook replay failed with ${replay.response?.status}`);
}
```

Retry the same replay with the same `idempotency_key` and the same body facts.
Changing replay parameters under the same key is a conflict and should be
treated as a caller bug.

One request enqueues at most `limit` (up to 500) events. The replay pins the
events committed when it starts, so continuing it neither skips nor repeats an
event, even when many share one timestamp. While a page says `has_more`, ask for
the next page after it; deriving the key from the page keeps retries exact:

```ts
let page = replay.data;
while (page?.has_more) {
  const next = await continueWebhookReplay({
    path: { replay_request_id: page.replay_request_id },
    body: { idempotency_key: `replay-after-${page.replay_request_id}`, limit: 500 },
  });
  if (next.error) {
    throw new Error(`webhook replay continuation failed with ${next.response?.status}`);
  }
  page = next.data;
}
```

`source_event_ids` lists the original event ids each page redelivers; they keep
their original `event_id`. A continuation whose window has left the 30-day
replay retention answers `410 webhook_replay_history_unavailable`: start a new
replay instead.

A replay covers the events of your sponsorship of the account: every event
created from the account's creation to its close, and the `account.closed`
that ended it. It makes no difference whether you ask before or after the
account closed.

### After You Upgrade Your Receiver

A receiver answers `2xx` to a well-formed event its build cannot read and
processes nothing. Answer it with a short JSON body whose `status` is
`unhandled`; Noctant's own receivers answer `200` with a body that carries
`"status":"unhandled"` and `"unhandled_reason":"unknown_type"`. Record nothing
for it in your deduplication store: an id you mark processed answers its
replay as a duplicate, and the event is lost. Log its `id`, `type` and
`created`.

Noctant keeps the start of each answer's body as the delivery's
`response_body_excerpt`. After you deploy a build that reads those events:

1. List them with `GET /v1/webhooks/deliveries` and `status` `delivered`. The
   ones your old build left unhandled are those whose
   `response_body_excerpt` says `"status":"unhandled"`; their `event_type`
   and `event_id` are on each delivery.
2. Replay them with `POST /v1/webhooks/replays`: their `event_types`, a new
   `idempotency_key`, `from_created_at` at the earliest unhandled event's
   `created`, and `to_created_at` after the latest one (the upper bound is
   exclusive). Without a log of `created`, take the time your old build went
   live and now: a wider window only redelivers events you answer as
   duplicates. Continue while a page says `has_more`.
3. Each event you processed before answers as a duplicate; each one you left
   unhandled is processed now, once. `operator-webhook-receiver-postgres`
   records nothing for an unhandled event, so its replay is a first delivery.

A replay reaches events created in the last 30 days, so replay within 30 days
of the first unhandled event.

## Retry Rules

- Retry network failures, `503`, and `429` with backoff. Respect
  `Retry-After` when present.
- For the request budget, error vocabulary, deployment windows, and reachability
  probe, see [Request Budget And Reachability](2026-06-29-external-journal-app-integration-guide.md#request-budget-and-reachability).
- When user/account creation has a retryable outcome, reuse the same external
  ids.
- When an endpoint with `idempotency_key` has a retryable outcome, reuse the
  same key and exact same business facts.
- Do not blindly retry `401`, `403`, or `404`; those are credential, scope, or
  identifier problems, or (`403` `feature_not_enabled`) a feature Noctant has
  not ticked for your company.
- Switch on the error body's `error` code, never on `detail` (a sentence for a
  human). Every error answer has the one `FirmError` body of the OpenAPI
  document.
- A `409` whose code is one of `journal_catching_up`,
  `account_command_applying`, `guards_not_acknowledged`,
  `attempt_end_applying`, `account_birth_applying`, `judgment_catching_up` or
  `browser_identity_retry_required` decided nothing: send the same request,
  with the same key, again after a short backoff (seconds). The Rust client's
  `OperatorApiClientError::retryable()` and the contract's
  `FirmErrorCode::is_retryable()` answer exactly this.
- `409` `payout_held` on `POST /v1/users/{global_user_id}/payout-requests` or
  `POST /v1/users/{global_user_id}/payout-requests/{payout_request_id}/approve`
  is outside that retryable set: some holds wait on the trader, on you, or on
  a review. Once its wait clears, send a held creation again with the same
  request and `idempotency_key`, or send a held approval again as it was; the
  held approval changed nothing. Every payout inside Noctant is simulated.
- For other `409` answers, inspect the error code and current state before
  deciding whether to resend.

## Required Private-Beta Smoke Evidence

Before the operator is considered enabled, the firm's staff and the app team
must record:

- Successful `GET /v1/me`.
- Test user external id and returned `global_user_id`.
- Test account external id and returned account id.
- Launch session id and successful browser redemption path.
- REST readback for summary, orders, positions, and trades.
- Signed webhook event id received and verified.
- Delivery-ledger readback for that event.
- Bounded replay request id.
- Replay idempotency check with same key/body.
- Replay conflict check with same key/different body.
- Firm staff's final onboarding signoff recorded after the evidence block is
  complete, with readiness showing `last_onboarding_signoff`.

Do not record API keys, webhook signing secrets, launch tokens, cookies, or raw
authorization headers in evidence.

## Changelog

What changed for a partner is its own file, newest first:
[`private-beta-partner-changelog.md`](private-beta-partner-changelog.md).
