Integrate with UnifyWire#
UnifyWire supplies read-only trading data to your SaaS backend. Your application authenticates its own users, requests their consent, asks for updates, and reads authorized stored data. It does not need UnifyWire source code or database access.
API origin: https://api.unifywire.com · API prefix: /api/v1 ·
Browser consent origin: https://www.unifywire.com ·
Download OpenAPI 3.1
This contract was checked against API implementation revision
4c5e1fd1a8ce140882a64b8121a527186f8e6e2a on October 24, 2026.
All example names, identifiers, prices and amounts below are fictional.
Replace placeholders; never use example credentials or nonce values.
1. What is available today#
| Capability | Contract and current limit |
|---|---|
| Organization-key authentication and tenant isolation | Implemented. The SaaS backend must also enforce its own user ownership. |
| Fresh cTrader account linking and explicit SaaS consent | Implemented; a signup-free customer return and server-to-server disconnect have been exercised independently. Each integrating SaaS must implement its own authenticated return handler. |
| Explicit asynchronous refresh, saved coverage and restart catch-up | Implemented. Full-gap/restart and stored-data retrieval have accepted evidence for an existing eligible Demo account. A request is not a provider-fetch or freshness guarantee. |
| Additional-account acquisition | Not automatically available for arbitrary linked accounts. The deployed worker currently serves a preapproved existing account scope. A new account or new SaaS recipient can link/select/queue successfully and still remain unserved. Arrange acquisition eligibility with UnifyWire before promising updates. There is no public endpoint that expands worker eligibility. |
| Trade reconstruction | cTrader HEDGED account type 0 is supported when sufficient retained evidence exists. Unsupported/unverified account types are reported explicitly. |
| Live accounts, other providers and shared-source operation | Do not infer support from a provider descriptor, a Live-format key or a successful connection. The documented central SaaS consent/selection/refresh flow currently requires a Test key and cTrader Demo. The complete multi-SaaS live acquisition journey remains unverified. |
| Retained re-consent | Implemented, with offline coverage; the complete independent retained re-consent browser journey remains unverified. It cannot silently restore a revoked permission. |
The API and website revision above were released and checked with an independent public-only stored-data client for an existing eligible Demo account. This is evidence of stored-data delivery, not a claim that a new customer account, open position, future trade or every financial field has been verified live. No completion time or continuous-freshness SLA is promised here.
2. Authentication, ownership and identifiers#
Send every API request from your server, over HTTPS:
Authorization: Bearer uw_test_<KEY_IDENTIFIER>.<KEY_SECRET>
The actual key format contains a 12-character identifier and 43-character secret using URL-safe characters. Keep the complete key in your server secret store. Never put it in browser JavaScript, a consent URL, query parameters, analytics, logs or a mobile application. Obtain an organization Test key through UnifyWire's existing key-management process; there is no public API-key creation endpoint in this contract. Issuing a Live key does not enable Live account synchronization.
The key resolves an organization and environment. It is organization-wide authority, not authentication of a particular trader. Do not accept arbitrary account or connection IDs from a browser and proxy them with this key. For every read, refresh, selection and disconnect, your backend must:
- Authenticate the journal user using your existing session.
- Resolve that user's durable UnifyWire end-user/connection/account mapping.
- Check ownership before making or returning the API request.
- Protect your own state-changing browser endpoints against CSRF and session replay. UnifyWire's server key does not perform your browser authorization.
Organization IDs cannot be overridden in onboarding request bodies. Foreign or
currently unauthorized resources generally return 404; do not infer existence
from a denial. Revoked keys return 401.
| Identifier | Meaning and handling |
|---|---|
Your externalId |
Stable, organization-scoped reference to your own signed-in user, for example journal:user:example-42. It is supplied by you, not a verified broker identity. |
endUserId |
UnifyWire canonical end-user UUID returned by POST /end-users. Save it in your user mapping. |
connectionId |
UnifyWire connection UUID for the consent/access relationship. It is not a provider token or global owner identity. |
Account id / route {accountId} |
UnifyWire canonical trading-account UUID. Use this in /accounts/{id} and selection URLs. |
providerAccountId |
Exact cTrader API account identifier, serialized as a string. It can exceed JavaScript's safe integer range. Do not convert it to a number or use it instead of the canonical UUID in routes. |
| Broker trading account number | May differ from the cTrader API ID. It is shown during consent and on the dashboard when known. The current public account DTO does not expose brokerAccountNumber or brokerName. Do not guess these from providerAccountId or parse them from displayName. |
authorizationRevision |
Optional opaque revision on a currently authorized central account. Save it for revision-pinned journal disconnect. It is not a credential. |
| Execution/trade IDs | Treat as opaque strings. Store execution facts by their canonical execution id; upsert reconstructed trades by id and calculationVersion. |
All UUIDs and provider identifiers in examples are fictional. In particular,
40000000-0000-4000-8000-000000000004 below is an example canonical account UUID.
3. HTTP conventions and errors#
Successful API responses use this envelope:
{
"data": { "example": "endpoint-specific fields" },
"meta": { "apiVersion": "v1", "source": "postgres" }
}
data is an object, array or collection according to the endpoint. A fixture
runtime can identify itself as meta.source: "development_fixture"; that is not
production/provider evidence. API JSON responses use Cache-Control: no-store.
An ordinary authenticated GET may update API-key usage/audit metadata, but it
does not request provider acquisition.
Send Content-Type: application/json for JSON onboarding bodies. Refresh,
selection, deselection and journal-disconnect requests require an empty body:
do not send {}, null, whitespace or a JSON content payload.
Errors have no success data or meta:
{
"error": {
"code": "not_found",
"message": "Selected trading account was not found"
}
}
| HTTP / code | Customer action |
|---|---|
400 invalid_request |
Correct the body, ID, query parameter or header. Do not retry unchanged input. Unknown pagination parameters are rejected. |
401 unauthorized |
Check the server's active key through the established key-management process. Do not repeatedly retry or expose it in diagnostics. |
404 not_found |
Wrong/unauthorized/unselected/ineligible resource, or an unsupported environment for that operation. Reconcile your user mapping and current access. |
409 conflict |
An existing identity/input, target or state conflicts with the operation. Reconcile; do not create a replacement connection automatically. |
412 precondition_failed |
The disconnect revision is missing where required or no longer matches. Fetch current authorized state and require the user's intended action again; do not automatically revoke a newer grant. |
503 service_unavailable |
Maintenance response; defer requests. |
503 backend_not_configured |
Required backend capability is unavailable. Retry later with bounded backoff or contact UnifyWire. |
500 internal_error |
Request outcome may be unknown. Reconcile with GET where available before repeating a mutation. |
The application does not currently define a general API-quota number, a refresh
429 contract or a Retry-After header. Hosting/proxy errors may also occur and
may not use the JSON envelope. Use bounded exponential backoff with jitter for
transient read failures. Keep application retries separate from UnifyWire's
durable provider retry/backoff. Do not blindly repeat an uncertain consent,
connection creation or disconnect.
4. Link an account with explicit consent#
Use the fresh SaaS permission-request flow for an external customer. UnifyWire dashboard Add account is an organization-owner flow; it is not a replacement for your customer session and explicit SaaS consent.
Create or recover your end-user mapping#
POST /api/v1/end-users
Authorization: Bearer <PRIVATE_TEST_API_KEY>
Content-Type: application/json
{
"externalId": "journal:user:example-42",
"displayName": "Example trader"
}
Response 201:
{
"data": {
"id": "20000000-0000-4000-8000-000000000002",
"externalId": "journal:user:example-42",
"displayName": "Example trader",
"createdAt": "2026-01-14T09:45:00.000Z",
"updatedAt": "2026-01-14T09:45:00.000Z"
},
"meta": { "apiVersion": "v1", "source": "postgres" }
}
Repeating the same externalId with the same displayName returns the same user
(still 201). A different display name for that existing external ID returns
409. This is a get-or-create identity contract, not an update endpoint. Strings
must be non-empty; these two fields are limited to 255 characters.
Start a fresh permission request#
Generate a fresh random return state on your server, for example
randomBytes(32).toString("base64url") in Node.js. Bind it to the initiating
signed-in session, canonical end user, request and expiry in a one-use server-side
receipt. The repeated A value below is a synthetic example only.
POST /api/v1/ctrader/permission-requests
Authorization: Bearer <PRIVATE_TEST_API_KEY>
Content-Type: application/json
{
"endUserId": "20000000-0000-4000-8000-000000000002",
"returnUrl": "https://journal.example/integrations/unifywire/return",
"returnState": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
}
Response 201:
{
"data": {
"requestId": "60000000-0000-4000-8000-000000000006",
"expiresAt": "2026-01-14T09:56:00.000Z",
"consentPath": "/connect/ctrader#request=60000000-0000-4000-8000-000000000006&proof=REDACTED_REQUEST_PROOF"
},
"meta": { "apiVersion": "v1", "source": "postgres" }
}
Both return fields are required together to initiate this signup-free fresh
flow. returnUrl must be canonical absolute HTTPS, at most 2,048 characters,
without credentials, query or fragment. returnState is canonical base64url of
exactly 32 random bytes. Send neither organization IDs nor provider credentials.
The request has a ten-minute expiry; provider continuation/review has a shorter
five-minute lifetime and can expire before the request. Do not extend it locally.
Direct the same initiating browser to
https://www.unifywire.com plus the returned consentPath, preserving its
fragment. Treat the complete URL as sensitive one-use material. Never log the
proof, send it to analytics or strip the fragment before navigation. Keep the
browser through provider authorization, UnifyWire review and the return link.
The trader reviews your organization/user labels, authorizes accounts-only, read-only Demo access at cTrader, checks the broker and exact account, explicitly chooses an account, then approves your SaaS. Provider authorization alone is not SaaS approval. Nothing is automatically selected for synchronization.
The alternative { "endUserId": "..." } request shape is implemented for
existing owner-reviewed sources; it is not the fresh signup-free flow above.
Legacy POST /connections followed by POST /connections/{id}/authorize is also
implemented for organization-owned OAuth connections. Do not substitute it for
fresh central customer consent or use it to adopt a revoked/shared source.
Validate the return on your server#
The browser's return URL carries status=approved or status=cancelled and your
opaque state. It carries no authoritative account IDs or provider token.
- Match and consume your original one-use receipt. Reject changed sessions, end users, replay, expired state or a mismatched request.
- With your private key, GET the saved request ID:
/api/v1/ctrader/permission-requests/{requestId}. - Require authoritative
status: "approved", then GET the returned connection and account using your backend. Require bothendUserIdvalues and their mutual account/connection membership to match your saved user mapping. - Store the canonical account/connection IDs and any
authorizationRevision. Do not automatically select/refresh as an unreviewed side effect of return.
Status example 200:
{
"data": {
"requestId": "60000000-0000-4000-8000-000000000006",
"status": "approved",
"connectionId": "30000000-0000-4000-8000-000000000003",
"tradingAccountId": "40000000-0000-4000-8000-000000000004"
},
"meta": { "apiVersion": "v1", "source": "postgres" }
}
An approved request is a historical consent result; it can remain approved after revocation. It is not proof that access is still current. Pending, expired or cancelled decisions are not grants. If a write/return result is uncertain, read the original request status; do not automatically create a replacement request.
Select separately, then confirm service eligibility#
PUT /api/v1/connections/30000000-0000-4000-8000-000000000003/accounts/40000000-0000-4000-8000-000000000004
Authorization: Bearer <PRIVATE_TEST_API_KEY>
Content-Length: 0
This requests the existing provisioning workflow. A selected account and
syncEnabled response are stored policy, not a running-worker or provider-proof
signal. GET /connections/{id}/accounts returns selected canonical account IDs;
it is not a provider-discovery endpoint. Use authorized /accounts and connection
membership to find linked accounts.
Current acquisition is restricted to an existing approved account scope. A new
SaaS connection can remain pending even after successful consent and selection.
UnifyWire must confirm its acquisition eligibility. Customers cannot enable a
worker, widen its account scope or supply provider credentials through this API.
Initial data has its own proven coverageFrom; do not assume all account history
or a particular initial lookback was acquired.
5. Request a refresh and wait for committed coverage#
POST /api/v1/accounts/40000000-0000-4000-8000-000000000004/refresh
Authorization: Bearer <PRIVATE_TEST_API_KEY>
Content-Length: 0
The body is empty. There is no date-range, force-refresh, callback, job ID or
Idempotency-Key input. Response 202 is asynchronous; it does not wait for the
provider. It returns the authorized stored data plus refresh status, including
when no new job was created because demand was shared or cooldown applied.
Complete synthetic pending response:
{
"data": {
"schemaVersion": "account-refresh-v1",
"tradingAccountId": "40000000-0000-4000-8000-000000000004",
"refresh": {
"status": "pending",
"requestedAt": "2026-01-14T10:10:05.000Z",
"requestedThrough": "2026-01-14T10:10:00.000Z",
"nextEligibleAt": "2026-01-14T10:15:05.000Z",
"retryAt": null
},
"stored": {
"available": true,
"state": {
"balance": "10000", "equity": "10000",
"marginUsed": null, "freeMargin": null, "currency": "USD",
"observedAt": "2026-01-14T10:00:00.000Z",
"providerObservedAt": "2026-01-14T10:00:00.000Z"
},
"sync": {
"tradingAccountId": "40000000-0000-4000-8000-000000000004",
"provider": "ctrader", "state": "current",
"coverageFrom": "2026-01-14T09:00:00.000Z",
"coverageThrough": "2026-01-14T10:00:00.000Z",
"coverageUpdatedAt": "2026-01-14T10:00:01.000Z",
"latestObservation": {
"observedAt": "2026-01-14T10:00:00.000Z",
"providerObservedAt": "2026-01-14T10:00:00.000Z",
"sourceVersion": null, "openPositionCount": 0
},
"latestRun": null
},
"freshness": {
"schemaVersion": "account-freshness-v1",
"tradingAccountId": "40000000-0000-4000-8000-000000000004",
"provider": "ctrader", "assessedAt": "2026-01-14T10:10:05.000Z",
"assessmentClock": "application",
"executionCoverage": {
"basis": "contiguous_execution_coverage_through",
"coverageFrom": "2026-01-14T09:00:00.000Z",
"coverageThrough": "2026-01-14T10:00:00.000Z",
"coverageUpdatedAt": "2026-01-14T10:00:01.000Z",
"status": "known", "ageMs": 605000, "reason": null
},
"coherentObservation": {
"basis": "retained_coherent_snapshot_observed_at",
"observedAt": "2026-01-14T10:00:00.000Z",
"status": "known", "ageMs": 605000, "reason": null
}
}
},
"statusUrl": "/api/v1/accounts/40000000-0000-4000-8000-000000000004/refresh"
},
"meta": { "apiVersion": "v1", "source": "postgres" }
}
stored.available means an account-state snapshot exists, not that it is fresh
or that complete trading history exists. With no snapshot it is false and
stored.state is null; inspect the separately returned coverage and status.
Cooldown, shared jobs and idempotency#
- The default cooldown is 300 seconds per underlying provider account, shared across endpoints and SaaS customers. It is not a read limit, an automatic five-minute poller or the maximum recoverable gap.
- Concurrent authorized requests share one pending upstream job. Your response remains tenant-local; it exposes no other customer's grant, account, receipt or shared job identity. Sharing work does not make another recipient eligible for acquisition or grant access to its data.
- During cooldown, POST can return
refresh.status: "cooldown", still with HTTP202. It creates no new job; continue reading authorized stored data.nextEligibleAtis admission timing, not a completion ETA or provider SLA. Another customer can cause shared cooldown even if you have no older receipt. - This is demand coalescing, not arbitrary request-key idempotency. After work is
terminal and cooldown expires, another POST can create new demand. There is no
public
Idempotency-Keyreplay contract. - UnifyWire can configure its cooldown independently; a five-second minimum abuse spacing, provider limits, execution bounds and failure backoff still apply. Customers cannot change those settings through the refresh body.
- Accepted jobs may recover long gaps through bounded internal batches and safe restarts without another customer POST per batch. Missing coverage is not reset or skipped. Provider retention, incomplete evidence or failures can prevent completion; there is no promise of unlimited recoverable history.
- Without demand there is no recurring trading-data polling. A SaaS that wants ongoing updates can schedule explicit refresh requests from its own server.
Status, correlation and completion#
GET the returned statusUrl on the API origin. This is the same resource:
GET /api/v1/accounts/40000000-0000-4000-8000-000000000004/refresh
Authorization: Bearer <PRIVATE_TEST_API_KEY>
It returns 200 with the same response schema. There is no public /jobs
endpoint, job ID, per-refresh receipt-ID URL, webhook completion callback or
refresh-cancellation endpoint. Ordinary status/data GETs do not queue or retry
provider work. Poll with a bounded client deadline and moderate backoff; for
example start at five seconds and increase to 30 seconds with jitter. These are
customer retry suggestions, not server poll timers.
Save {accountId, requestedAt, requestedThrough} from the accepted response.
The GET exposes your latest receipt, not a browsable history of individual jobs.
Do not issue overlapping refresh cycles from your own application. If a later
request replaces that tuple, do not call it the original job: reconcile the
original cutoff against authorized committed coverage and manage the newer
receipt separately.
refresh.status |
Interpretation |
|---|---|
idle |
No retained receipt is returned for you. It does not establish freshness. |
pending |
Demand is saved; work may be waiting for an eligible worker. |
running |
Current account acquisition authority was observed. This is not a socket-health SLA. |
retry_wait |
Durable failure backoff applies; retryAt is supplied. Wait and read status; do not bypass it with repeated POSTs. |
completed |
Your current grant's committed execution coverage reaches at least this receipt's requestedThrough. |
failed |
Terminal job failure budget/state; do not treat partial progress as complete or loop POSTs automatically. Preserve last good data and reconcile. |
cancelled |
Work/receipt is cancelled or its authority is no longer current. Access may instead return 404. |
cooldown |
POST-only admission result; no new refresh was accepted. GET may show the older receipt's actual status. |
For newly created shared work, the cutoff is approximately database time minus
five seconds, rounded to milliseconds. A request joining pending work inherits
that job's existing timestamps and older cutoff. Always use the returned
requestedThrough, not your POST's wall-clock time. Completion can cover beyond
it. Completion does not
require a fresh account observation and does not mean current wall-clock data.
Always assess stored.freshness.executionCoverage and
stored.freshness.coherentObservation independently. Null/unknown ages and
clock-ahead evidence are not zero-age data.
GET /accounts/{id}/sync exposes committed coverageFrom, coverageThrough,
coverageUpdatedAt, latest observation and latest run. A succeeded internal
batch is not completion of the entire refresh. sync.state: "current" means
retained success/coverage, not recent data. A later failed run does not erase
already committed history. GET /accounts/{id}/operations adds recorded policy,
activity, lastSucceededRunAt and freshness; policy enabled, idle, nextDueAt
or a cadence value does not prove a running worker or promise automatic polling.
6. Retrieve executions and reconstructed trades#
Use /accounts/{id}/executions for fill-level facts and
/accounts/{id}/trades for derived position lifecycles. Do not interpret the two
partial closing fills of one position as two independently opened trades.
Executions: immutable fill facts#
Example GET /accounts/{id}/executions?limit=50, response 200:
{
"data": {
"items": [
{
"id": "50000000-0000-4000-8000-000000000001",
"organizationId": "10000000-0000-4000-8000-000000000001",
"endUserId": "20000000-0000-4000-8000-000000000002",
"connectionId": "30000000-0000-4000-8000-000000000003",
"tradingAccountId": "40000000-0000-4000-8000-000000000004",
"provider": "ctrader", "providerExecutionId": "70001",
"providerOrderId": "60001", "providerPositionId": "80003",
"symbol": "EURUSD", "side": "buy", "quantity": "2000",
"price": "1.1", "grossPnl": null, "commission": "-0.1",
"swap": "0", "fees": "0",
"executedAt": "2026-01-14T10:00:00.000Z",
"providerObservedAt": "2026-01-14T10:10:01.000Z"
}
],
"page": { "limit": 50, "hasMore": false, "nextCursor": null }
},
"meta": { "apiVersion": "v1", "source": "postgres" }
}
This example illustrates one fill, not an entire three-fill account download.
grossPnl: null on an opening fill is expected; it is not a realized zero.
side is that fill's side, so sells may close a long position.
Quantities, money, timestamps and provenance#
- Financial values and quantities are decimal strings. Use Decimal/fixed-point arithmetic. Preserve nulls and exact IDs; do not round through binary floats.
- cTrader execution/position quantities are account units, normalized from
provider volume divided by 100. They are not lots. Use
/instrumentsmetadataunitsPerLotonly when available/applicable; never assume every symbol has 100,000 units per lot. Present-day metadata does not prove historical lot size. - Money is scaled from provider integers using its recorded
moneyDigits. Commission, swap and fees are signed adjustments: negative charges reduce P&L; positive rebates/credits increase it. Do not take absolute values or negate them again. - Here,
feescovers the retained P&L conversion fee, not a guarantee that all possible broker fees are represented. - The execution DTO's non-null
"0"commission/swap/fees may come from a default or a not-applicable case. Zero does not by itself prove the provider reported no cost./execution-detailsexposesreported,defaulted_zero,not_applicableand unavailable provenance, currency basis and timestamp origins. Consult it when cost completeness matters. Do not add the close-detail allocation of opening commission to executed-fill commission a second time. executedAtis the provider execution time in UTC. Do not assume every field namedproviderObservedAtis a broker event time: execution details identify application acquisition timing; account-stateobservedAtis a coherent local capture, and itsproviderObservedAtcurrently uses the local P&L response time. Position timestamps can use a provider update time or an observation fallback. Timestamps are ISO 8601 unless a field explicitly says otherwise; retained financial-evidence points usetimestampMs, an integer string of UTC epoch milliseconds. Retain full precision.- Missing provenance, currency, scale or evidence is unavailable, not an invented value. Do not assign a historical currency from today's account currency when the cost or cash record says its currency basis is unavailable.
Reconstructed position lifecycles#
For this fictional account, suppose the opening above is followed by two closes:
| Fill | Side / units | Price | Realized gross | Fill commission | Swap | Fees |
|---|---|---|---|---|---|---|
| Open | buy / 2000 | 1.1 | null | -0.1 | 0 | 0 |
| Partial close | sell / 1000 | 1.101 | 1 | -0.05 | 0 | 0 |
| Final close | sell / 1000 | 1.102 | 2 | -0.05 | 0 | -0.02 |
With a proven opening boundary and contiguous coverage, /trades returns one
closed lifecycle. Full synthetic response:
{
"data": {
"items": [
{
"id": "57d85db2-9b88-8745-b2e6-1e9073fcd0c7",
"organizationId": "10000000-0000-4000-8000-000000000001",
"tradingAccountId": "40000000-0000-4000-8000-000000000004",
"provider": "ctrader", "calculationVersion": "ctrader-hedged-lifecycle-v1",
"lifecycleKey": "ctrader:position:80003", "providerPositionId": "80003",
"openingAbsenceObservedAt": "2026-01-14T09:59:59.000Z",
"derivationStatus": "complete", "lifecycleStatus": "closed",
"incompleteReasons": [], "unsupportedReasons": [],
"symbol": "EURUSD", "side": "buy",
"openedQuantity": "2000", "closedQuantity": "2000", "remainingQuantity": "0",
"averageEntryPrice": "1.1", "averageExitPrice": "1.1015",
"realizedGrossPnl": "3", "commission": "-0.2", "swap": "0",
"fees": "-0.02", "netPnl": "2.78", "netPnlStatus": "complete",
"openedAt": "2026-01-14T10:00:00.000Z",
"closedAt": "2026-01-14T10:10:00.000Z",
"firstExecutionAt": "2026-01-14T10:00:00.000Z",
"lastExecutionAt": "2026-01-14T10:10:00.000Z",
"sourceExecutionIds": [
"50000000-0000-4000-8000-000000000001",
"50000000-0000-4000-8000-000000000002",
"50000000-0000-4000-8000-000000000003"
],
"derivedAt": "2026-01-14T10:10:01.000Z"
}
],
"page": { "limit": 50, "hasMore": false, "nextCursor": null },
"capability": {
"available": true, "calculationVersion": "ctrader-hedged-lifecycle-v1",
"status": "complete", "coverageFrom": "2026-01-14T09:59:59.000Z",
"coverageThrough": "2026-01-14T10:10:01.000Z",
"inputExecutionCount": 3, "tradeCount": 1, "completeTradeCount": 1,
"incompleteTradeCount": 0, "unsupportedTradeCount": 0
}
},
"meta": { "apiVersion": "v1", "source": "postgres" }
}
Net P&L is realized gross plus the signed commission, swap and fees:
3 + (-0.2) + 0 + (-0.02) = 2.78. The exit price is quantity-weighted.
While a lifecycle remains open or partly closed, final netPnl can be null
with netPnlStatus: "pending_full_close". A complete derivation can describe an
open lifecycle; inspect both derivationStatus and lifecycleStatus.
Collection capability and per-trade reasons must be checked. Missing opening
absence evidence, incomplete/gapped history, unverified/unsupported account type
or unsupported execution patterns cannot be repaired by assuming a flat endpoint
or by inventing executions. Whole reconstruction input is capped at 5,000
executions, separately from page size and recoverable provider history. Above
that cap the route returns an explicit unavailable capability; raising limit
does not bypass it. An empty items list without checking capability is unsafe.
Executions are immutable facts: repeated identical evidence is deduplicated and
conflicting content fails closed. There is no public execution-edit endpoint or
correction/change feed. Reconstructed trades are recalculated from retained
evidence; the same stable trade ID can acquire later closes, costs or a different
completeness state. Upsert it rather than appending a second trade, and retain
its calculation version and source execution IDs. derivedAt is based on retained
source execution creation times, not the GET time. It can change as input changes,
but is not a snapshot/revision token: changes to coverage or opening-boundary
evidence need not advance it. Weighted average prices use 50 significant digits
with half-even rounding; retain source fill prices for original precision.
7. Balances, positions, orders and unavailable financial values#
GET /accounts/{id} returns latestState, which can be null. Its balance/equity
are the latest stored observation, not a balance at each historical execution or
at the final close of every trade. The cTrader account-state projection currently
leaves margin-used/free-margin values null. /observations and
/observations/{observationId} provide retained coherent snapshots and their
observation/acquisition provenance.
/positions returns retained position rows, including closed rows; it is not
guaranteed to be an open-only list. A position disappearing from a complete
snapshot can be marked closed with zero remaining quantity while closedAt and
gross P&L remain null. Do not treat that observation time as the actual execution
close time. Prefer reconstructed lifecycles for trade closing facts.
| Requirement | What you can actually rely on |
|---|---|
Per-fill or per-trade balanceAfter / finalCloseBalance |
Absent from executions, execution details and reconstructed trades. No general balance-at-close endpoint exists. |
| Conditional replay balance at full close | /financial-evidence?from=...&through=... can expose an after_lifecycle / full_close replay point with balance and lifecycleExecutionId only for an exact retained, complete interval. It is conditional reconstructed evidence, not an always-present broker field. Join the lifecycle execution to your fill; never invent a point if capability/evidence is missing. |
| Reconstructing final-close balance yourself | Do not add every fill commission to starting balance. Retained replay allocation can include accrued opening/closing commission differently from an individual closing fill's commission. Use the supplied evidence balanceDelta/balance and provenance when available. |
| Orders | /orders contains retained order observations/revisions, not a trading endpoint or proof of a complete historical pending-order timeline. |
| Deposits and withdrawals | /cash-movements contains retained balance movements, amount/balance/equity only when scale is supported. Its current currency is always null with currencyBasis: "unavailable"; do not infer historical currency. Cash balanceAfter is not a trade final-close balance. |
| Order/cash coverage | /history-requests lists bounded collection receipts. Their complete request response is explicitly not execution or full financial coverage; boundary/filter semantics differ. There is no customer POST to that route to fetch arbitrary history. |
| Symbol / lot metadata | /instruments and /execution-instruments expose retained normalized metadata/provenance. Missing or archived/unmatched information is unavailable, not a license to assume units or current-to-historical applicability. |
Financial evidence, its interval discovery and chain endpoints are reads over retained data. They do not initiate a provider fetch. Their exact ranges, precision, point/payload and chain limits are described in the OpenAPI contract; do not substitute those read limits for total recoverable history.
8. Pagination and consistent reconciliation#
Collection responses use data.items and data.page:
{ "items": [], "page": { "limit": 50, "hasMore": false, "nextCursor": null } }
This is a collection fragment; the HTTP response still has the outer data and
meta envelope. Follow nextCursor unchanged until hasMore is false.
URL-encode it; treat it as opaque and bound to the same resource/account.
Only limit and cursor are accepted on standard paged routes; there is no
generic from, updatedSince, endUserId, sort or date-filter parameter.
Financial-evidence routes have their own explicit query contract instead.
| Collection | Default / maximum limit |
Order |
|---|---|---|
| Connections, accounts, positions, executions, instruments, execution-instruments, trades | 50 / 100 | Ascending resource time, then ID; execution order is executedAt, then ID. |
| Observations | 1 / 5 | Newest first. |
| Execution details | 5 / 5 | Ascending execution time, then ID. |
| Orders, cash movements, history requests | 5 / 5 | Newest observations/receipts first. |
Repeated/unknown pagination parameters or an out-of-scope cursor return 400.
GET /providers, a selection response, status response and permission lineage
are not standard paged collections.
Pagination is keyset traversal, not a cross-page snapshot, signed snapshot token, delivery acknowledgment or append-only change cursor. Different GETs can observe later committed data; each read rechecks authorization. New backfilled rows before an old cursor can be missed if it is used as a permanent watermark.
After a completed refresh, reconcile the account from a fresh first page, deduplicate execution IDs and upsert trade IDs. Read coverage/observation and any authorization revision before and after the traversal; if those changed, restart the affected reconciliation. Do not merge partial pages from different accounts, recipients, resource types or revoked permissions. The API currently has no public immutable multi-resource snapshot ID or change feed.
9. Disconnect only your journal's access#
Your trader disconnects inside your authenticated journal. They do not need a UnifyWire owner cookie, UnifyWire login or 30-day renewal for this action.
Use the end-user-scoped route from your backend, with an empty body. Pin the
intended consent generation using the current account's authorizationRevision
saved in your server-owned mapping before revocation:
DELETE /api/v1/end-users/20000000-0000-4000-8000-000000000002/connections/30000000-0000-4000-8000-000000000003
Authorization: Bearer <PRIVATE_TEST_API_KEY>
If-Match: "unifywire-customer-authorization-v1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
Content-Length: 0
Response 200:
{
"data": {
"connectionId": "30000000-0000-4000-8000-000000000003",
"status": "disconnect_requested",
"cleanupState": "requested",
"workerCleanupConfirmed": false
},
"meta": { "apiVersion": "v1", "source": "postgres" }
}
The quoted revision is an opaque value from the API, not a hash you compute.
Malformed If-Match is 400; a different/newer grant or missing revision where
lineage requires one is 412 without revoking that newer permission. Headerless
single-generation compatibility is implemented, but do not build a new journal
integration around it: successor consent makes the revision necessary.
This operation checks your organization, exact end-user mapping, connection and
central account permission. Wrong tenant/user or unsupported legacy access is
not granted through this route. Repeating the same revision is idempotent;
it does not create another withdrawal audit. An unknown result must be reconciled
before another write. The organization-wide DELETE /connections/{id} is a
separate legacy-compatible operation without the end-user assertion/If-Match
handling; it is not the recommended journal-user disconnect route.
Revocation blocks later authorized reads and prevents revoked queued deliveries or later publication under that grant. In-flight work is checked/fenced and cancellation is requested. A read admitted before revocation may finish; a response already in transit cannot be recalled. A disconnect response does not confirm every socket/process has stopped. Shared provider sources, encrypted credentials and retained history are not deleted as a side effect of withdrawing one central SaaS permission. Another SaaS's independent authorized access remains. With no authorized selected consumers, that account loses acquisition eligibility; pending/running work is cancelled or settled safely. Internal delivery records do not imply a public webhook/subscription API exists.
DELETE /connections/{id}/accounts/{accountId} is deselection, not withdrawal
of the SaaS permission. It requests cancellation of that account's acquisition.
Do not label it “disconnect journal” or assume it denies all authorized history
reads. Global owner source disconnect and provider-side cTrader revocation are
also separate protected operations.
Reconnect and retained identity#
Revoked permission never silently becomes active because the broker/profile
number matches, a token refresh succeeds or the browser logs in again.
Re-consent must be a fresh explicit SaaS decision. The retained-consent request
shape is implemented on POST /ctrader/permission-requests:
{
"endUserId": "20000000-0000-4000-8000-000000000002",
"reconsent": {
"connectionId": "30000000-0000-4000-8000-000000000003",
"tradingAccountId": "40000000-0000-4000-8000-000000000004",
"previousAuthorizationRevision": "unifywire-customer-authorization-v1:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
},
"returnUrl": "https://journal.example/integrations/unifywire/return",
"returnState": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
}
Use a fresh random state, not the example or an earlier receipt. Where the
retained connection, original owner/source, settled withdrawal and browser
authority are eligible, approval creates a new authorization revision while
preserving the canonical connection/account and the revoked predecessor.
/accounts/{id}/permission-lineage exposes the authorized current lineage.
Eligibility is rechecked; an existing UUID is not proof it can be re-consented.
The complete independent retained browser journey is still unverified.
The separate browser-owner management credential lasts 30 days and requires
explicit renewal while valid. That is for global owner management, not journal
disconnect. Losing/expiring it cannot recover a source merely by matching
account or profile IDs. Provider reauthorization/source repair is distinct from
grant re-consent; there is no generic public /reconnect endpoint or guaranteed
automatic history transfer/owner adoption. Do not fall back to a fresh connection
after an uncertain retained request.
10. End-to-end backend walkthrough#
- Authenticate and map: obtain a Test key; authenticate the trader in your journal; get-or-create an end user and save its canonical ID.
- Link: generate your one-use session-bound return state; POST the fresh permission request; send that same browser to the exact returned consent URL.
- Validate return: recheck your session/state/expiry, GET the saved request,
then GET its current account and connection. Save their user mapping and
authorizationRevision; never trust browser status alone. - Select explicitly: empty PUT the exact account selection. Confirm with UnifyWire that this recipient/account is in the currently served acquisition scope. A linked/selected account alone cannot prove it.
- Refresh: empty POST
/accounts/{id}/refreshonce. Save the exact request timestamps/cutoff; return stored data and a pending label to your user. - Wait: GET the same status URL with a bounded polling policy. Respect cooldown/backoff. Handle failed/cancelled/denied states; do not submit a POST for every catch-up batch or mistake one succeeded batch for completion.
- Retrieve: once the receipt is completed and its cutoff is covered, read freshness independently. Traverse executions/trades from the first page, dedupe/upsert, check reconstruction capability and reconcile concurrent changes. Preserve Decimal strings, signed costs, nulls and provenance.
- Disconnect: authenticate/authorize the user and protect your browser action against CSRF; your backend DELETEs the exact mapped end-user connection with its saved quoted revision. Verify later account/connection reads are denied. Record withdrawal, while showing cleanup as requested rather than confirmed.
The OpenAPI download describes all implemented /api/v1 methods and schemas,
including optional retained-evidence routes. No step requires private SQL, source
access, provider tokens in the journal, or a UnifyWire owner cookie for journal
disconnect. Eligibility and unsupported data remain real limits, not steps your
client should work around.
11. Remaining contract gaps#
- Additional-account/recipient acquisition is not self-service enabled by linking; current worker restrictions must be resolved operationally before that account can supply updates. Documentation publication does not change eligibility.
- The full retained re-consent and multi-SaaS shared-source live journeys remain unverified. Shared-job isolation tests are not equivalent live proof.
- Public account DTOs omit the broker-facing account number/broker label that consent and dashboard views can display.
- There is no general execution/trade balance-after/final-close balance field, guaranteed complete fee taxonomy, cash-movement currency, historical symbol applicability guarantee or universal account-mode/Live support.
- There is no public individual job ID/history/cancel API, webhook subscription, correction/change feed or cross-request snapshot token. Use the actual latest receipt, coverage, current access and reconciliation semantics above.
- No general request-per-minute quota, completion SLA or
Retry-Aftercontract is specified by this application. Keep client work bounded and handle hosting failures without assuming they follow the application JSON envelope.