> ## Documentation Index
> Fetch the complete documentation index at: https://developers.securitize.io/llms.txt
> Use this file to discover all available pages before exploring further.

> ## Agent Instructions
> SecId API: use the sandbox base URL https://partners-api.sandbox.securitize.io/investors/v1/onboarding-reliance unless the user explicitly provides production access. Authenticate with OAuth 2.0 client credentials: POST https://partners-api.sandbox.securitize.io/oauth2/token (form fields grant_type=client_credentials, client_id, client_secret, read from environment variables), cache the bearer token for its expires_in (7200 s) and send it as Authorization: Bearer on every call; never print or log the secret or the token.
> Nothing is verified until POST /investors/{investorId}/verification/submit. An individual: create, upload one identity document, submit. A company: create, add its people as key parties (at least one with isBeneficialOwner: true), upload their documents, submit.
> Branch on the HTTP status and the error `code`, never on `message`. A body without `code` comes from the gateway: on a 401 get a new token and send the request again; a 403 means the account is not enabled for this API.
> SecId API addresses: send address.state only when that address's countryCode is US, as the 2-letter state code (NY); omit it for any other country, also when the user has a province or region. Otherwise the call answers 400 validation_error on address.state.
> SecId API individuals in the US: taxId is the person's Social Security number, and it is checked against the name and residential address sent; send them as registered. A mismatch can move the investor to manual-review instead of verified. Sandbox does not run the check.
> Creates are safe to repeat with the same body: after a timeout or a 502, send the identical create again. Document uploads are not: each call stores a new document, so only re-send a document after a 502.
> Poll GET /investors/{investorId} about every 30 seconds, then back off; treat any status you do not recognise as not verified. In sandbox, the webhook investor.kyc.updated also announces every status change, and investor.suitability.updated / investor.dlt_test.updated every change of the suitability and DLT test verificationStatus: verify the signature, deduplicate on sourceEventId and confirm with the matching GET before acting. Token whitelisting is asynchronous too: poll GET /investors/{investorId}/wallets/whitelist until the status leaves processing.
> SecId API tax forms: POST /investors/{investorId}/tax-forms takes multipart/form-data (parts file, formType, signatureDate); never base64 or JSON. Let the HTTP client set content-type with the boundary. At any point of the verification, before or after the investor is verified, only for investors you created (201), one PDF up to 10 MB per call; each upload replaces the form on file, so after a 502 send the same request again. A W-9 can instead go as JSON fields to POST /investors/{investorId}/tax-forms/w9 (TIN as 9 digits, SSN or EIN): it does not replace a form on file (409 tax_form_already_active, details.activeTaxForm), and it requires a certification object (certifiedAt, signatureName, subjectToBackupWithholding false, isUSPerson true, certificationTextVersion w9-sm-2026-10): the investor certifies in your flow on the text published at /secid-api/tax-forms#the-certification, shown without changes; subjectToBackupWithholding true answers 422 business_rule_violation (details.reason backup_withholding_not_supported). Unlike creates, a repeat after success answers 409 tax_form_already_active, not 201: when details.activeTaxForm.formType is W-9, treat it as on file.
> Use synthetic test data in sandbox (@test.invalid emails, fresh ids each run). Never send more than one DLT test attempt per test investor unless the user asks: three failed attempts lock the test permanently.
> The complete contract, with every field, response and error, is the OpenAPI file of the API reference; the Errors page says what to do for each code.
> CET Underlying API: a WebSocket at wss://partners-rt-gw.sandbox.securitize.io/v1/market-maker/stream (TLS only; ws:// hangs with no answer). Sign hello, place_order, cancel_order and submit_crypto_funding with the user's Ed25519 key, read from an environment variable or secrets store; never print or log it. Sign timestamp + "." + type + "." + the exact payload string you send: serialise the payload once and embed that same string in the frame.
> CET Underlying API: send a unique requestId of at most 32 characters (a UUID without hyphens) on every message and match replies on it; frames without a requestId are pushes. Every amount is a decimal string, never a JSON number; compare amounts as decimals. Branch on the error code, never on message.
> CET Underlying API: an ack to place_order means taken, not filled. externalRef is the idempotency key; after an ack with status UNKNOWN or an UPSTREAM_UNAVAILABLE error, call get_order by externalRef before resending. Updates are delivered at most once: after any reconnect, read working orders back with get_order. Every order is placed by quantity. A buy must be a LIMIT order: quantity × limitPrice plus fees is held against your own available cash (get_cash_balance_v2), and a buy it does not cover, a MARKET buy or a notional order is refused (INVALID_ORDER). A sell must be placed by quantity, after its CETs reach the redemption wallet, or it is refused. mint_updated and burn_updated push mints and burns once they are on chain; after a gap, read them with get_mints and get_burns. The complete contract is the AsyncAPI file of the API reference.
> Stocks API (Beta): documented ahead of its release through the partner gateway; paths, fields and errors can change. Base URL https://partners-api.sandbox.securitize.io/stocks/v1. Send x-api-key on every call and investorId on every call about an investor. Read the key from an environment variable; never print or log it, and never call the API from frontend code.
> Stocks API: an investor can trade only once the SecId API shows them verified, with verified suitability (and a passed DLT test in the EEA), and once their wallet on the stock's chain is linked with their signature of the agreement message, signed verbatim. There is no whitelisting call: the first trade whitelists the wallet.
> Stocks API: orders are fill or kill. GET /stocks/realtime-price, prepare within about 20 seconds (signedPrice), have the investor's wallet sign the returned transaction unchanged and sign-only (never send it), then submit it once with POST /stocks/execute-signed-transaction within 120 seconds. Amounts are raw base units as strings; USDC has 6 decimals. The outcome is on chain: follow transactionHash.
> Stocks API: production access requires a UI audit by Securitize Compliance of the live screens: the disclosures and the market data shown, the NBBO included.
> Corporate Actions API (Beta, 0.x): in development and subject to change; paths, fields, enumerations and event names can change before 1.0.0. Base URL https://partners-api.sandbox.securitize.io/corporate-actions/v1. Get a bearer token with OAuth 2.0 client credentials at POST https://partners-api.sandbox.securitize.io/oauth2/token (no scope, 2 hours), cache it, and send Authorization: Bearer on every call. Read the client id, secret and the webhook signing secret from environment variables; never print or log them, and never call the API from frontend code.
> Corporate Actions API: webhooks arrive signed with the Standard Webhooks scheme under the svix-id, svix-timestamp and svix-signature headers (HMAC-SHA256 over id.timestamp.raw body, base64, v1 prefix; the secret is base64 after whsec_). Verify before parsing, reject a timestamp more than 5 minutes off, answer 2xx within 10 seconds. Delivery is at least once and can be out of order: deduplicate on sourceEventId, keep the highest version per object (objectId), order by sequence per stream.id, and replace the object with data (never merge).
> Corporate Actions API: treat an unknown caType, reasonCode, eventType or enum value as informational, never as an error. A restriction ends only with ca.restriction.removed; expectedEnd is informational. marketStatus UNAVAILABLE means do not trade; the reason field carries a restriction reasonCode or a market reason such as PRE_MARKET. GET /tokens?status= filters by marketStatus (OPEN, CLOSED, UNAVAILABLE), not by the token's ACTIVE status.
> Corporate Actions API: the event routes are at https://partners-api.sandbox.securitize.io/webhooks/v1 (not under /corporate-actions/v1) and cover every webhook family sent to the partner; filter with eventTypes=ca.* for corporate actions only. After an outage call POST /events/recover with since at the start of the outage (resends failed deliveries only), or page GET /events?since= (newest first, iterator paging, 90 days), then read state with GET /tokens/{tokenId}/status and GET /corporate-actions/{sourceCaId}. In sandbox, POST /sandbox/events with a scenario and one of your tokenIds publishes a scripted sequence at once; order the events by sequence, not by arrival.
> Investor Activity API: base URL https://partners-api.sandbox.securitize.io/v1. Authentication is OAuth 2.0 client credentials only (no API key): POST https://partners-api.sandbox.securitize.io/oauth2/token (no scope, 2 hours), cache the token, and send Authorization: Bearer on every call. The token identifies the investor account; there is no investor id to pass. Read the client id, secret and the webhook signing secret from environment variables; never print or log them, and never call the API from frontend code.
> Investor Activity API: every amount is an integer string in base units with a sibling decimals (human = amount / 10^decimals); parse with BigInt or a decimal type, never a float. Cash amounts (deposits, confirmations, payouts) are in the minor units of currencyId (1 = USD, decimals 2); token amounts (transactions, mintings, holdings) in the token's base units. Never add cash to tokens.
> Investor Activity API: reconcile a subscription by grouping GET /deposits?tokenId= on tokenIssuanceId and matching the transaction in GET /transactions?tokenId= with the same tokenIssuanceId (null on a deposit means not issued yet; null on a transaction means not an issuance). Each issuance has a deposit movement (cash in) and a liquidation movement (that cash applied to the tokens) for the same amount: count the cash once, by direction=deposit, never by adding every movement. Page with offset and limit (max 100) until offset + meta.length reaches meta.total; there is no tokenIssuanceId filter. Reconcile a redemption by txHash (transfer, investor.redemption.pending, payout sourceTransaction) and by the redemption's data.id across pending, confirmed, payout.initiated and payout.completed.
> Investor Activity API: webhooks arrive signed with the Standard Webhooks scheme under the svix-id, svix-timestamp and svix-signature headers (HMAC-SHA256 over id.timestamp.raw body, base64, v1 prefix; the secret is base64 after whsec_). Verify before parsing, reject a timestamp more than 5 minutes off, answer 2xx within 15 seconds. The body is {eventType, eventId, timestamp, data}: deduplicate on eventId (not svix-id), upsert by data.id, never move a record back to an earlier step, and treat an unknown eventType, processType or paymentMethod as informational. In webhooks a field with no value is absent; in endpoint answers it is null. After an outage page GET /webhook-events (newest first, iterator paging, optional eventType) back to the start of the outage.
> SecId hosted onboarding: instead of the API, a partner can send an investor to https://stocks.securitize.io/partners?tokenId=<stock token id>&partnerUrl=<return URL> (tokenAddress may replace tokenId; partnerUrl must be an absolute https URL, URL-encoded). Securitize runs identity verification, the investment profile and the wallet there. Nothing is appended to partnerUrl on return: check the wallet on chain before trading (Beta on Solana).

# Errors

> How errors look, what to do about each code, what is safe to retry, and what to send support.

Every operation in the API reference lists the errors it can return. This page is the overview: how to read an error, what to do with each code, and what to send Securitize when you need help.

## How an error looks

**API errors** carry a `code`. Branch on the HTTP status and the `code`; `message` is for humans and can change. Quote `correlationId` when you contact Securitize support.

```json theme={null}
{
  "statusCode": 409,
  "code": "external_investor_id_conflict",
  "message": "An investor with externalInvestorId \"acme-1001\" already exists for this partner, and this request does not repeat its create. Use the correction endpoint to change its data.",
  "details": { "investorId": "6aca84e5dc49fe8369dd1da4" },
  "correlationId": "partners-api-d3dcfc7c-a3d0-459e-98a5-9d6f23dcb998"
}
```

`details` depends on the error:

* a validation error lists every field to fix: `[{ "field": "phone.code", "message": "code must contain digits only" }]` (on a query parameter, a list of strings);
* a conflict carries the existing id: `{ "investorId": … }` or `{ "keyPartyId": … }`;
* a refused submit names who is short: `{ "investor": …, "keyParties": [ … ] }`;
* a whitelisting waiting for suitability names what: `{ "notVerified": ["us", "eu"] }`;
* a tax form of the wrong type lists the forms the investor can receive: `{ "allowed": ["W-9", "W-8BEN"] }`;
* a W-9 sent as data while the investor has a form on file names that form: `{ "activeTaxForm": { "taxFormId": …, "formType": "W-8BEN" } }`;
* a W-9 sent as data for an investor subject to backup withholding says why: `{ "reason": "backup_withholding_not_supported" }`.

**Gateway errors** have no `code` and no `correlationId`: the request never reached the API. A `401` (`{"error": "invalid_request" | "invalid_token", "error_description": …}`) means the bearer token is missing, unknown or expired: get a new one and send the request again. A `403` `{"message": "You cannot consume this service"}` means your account is not enabled for this API ([Sandbox and authentication](https://developers.securitize.io/secid-api/sandbox-and-auth.md#when-the-token-is-wrong)); a `403` `IP address not allowed` means the request is outside the API, for example a path outside `/investors` or a `DELETE`. A body that is not valid JSON answers `400` without a `code` either.

## What to do

### Fix the request and send it again

| Status | `code` | Typical cause |
| - | - | - |
| `400` | `validation_error` | A field is missing or malformed; `details` lists every one, so fix them together. Includes a missing or placeholder `taxId` or `entityIdNumber`, and a wallet address that does not match its family. On a tax form, a second file, a file in a part not named `file`, or a `content-type` set by hand without its boundary says so in `message`, without `details` |
| `400` | `invalid_document` | A document's metadata: unknown `type`, wrong number of files or `side`, or a `mime` that does not match the file. As multipart: a file part that does not belong to the document, an empty file, or a `content-type` set by hand without its boundary. See [Documents](https://developers.securitize.io/secid-api/documents.md#three-ways-a-document-is-refused) |
| `400` | `invalid_include` | An `include` value that does not exist; the message lists the allowed ones |
| `413` | `payload_too_large` | A document file over 10 MB as JSON or over 20 MB as multipart, a tax form over 10 MB, or a JSON request over about 31 MB |
| `415` | `mime_not_supported` | A file type the document does not accept, or a request without `content-type: application/json` (on the document routes, also accepted: `multipart/form-data`). On a PDF tax-form upload: a file that is not a PDF, or a body that is not `multipart/form-data` |
| `422` | `document_rejected` | The image could not be read. Send a better scan |
| `422` | `tax_form_type_not_allowed` | The tax form does not apply to the investor's type. Send one of the forms in `details.allowed`. See [Send a tax form](https://developers.securitize.io/secid-api/tax-forms.md#which-form) |
| `422` | `business_rule_violation` | Well-formed but not allowed here; the message says why. On submit: an individual without an identity document, a key party missing documents, or a company with no person among its key parties. On a wallet: the wallet limit or the risk check. On the DLT test: an investor outside the EEA, or the test is locked. On the suitability or the compact suitability: a verdict under compliance review or not approved (`details.reason`: `suitability_under_review`, `suitability_not_approved`). On the compact suitability: an entity (`details.reason: investor_type_not_supported`). On a W-9 sent as data: an investor subject to backup withholding (`details.reason` `backup_withholding_not_supported`); contact support for that investor |

### Use what already exists

| Status | `code` | What to do |
| - | - | - |
| `409` | `external_investor_id_conflict` | You already created this investor: the same `externalInvestorId` with different data, or the same tax or registration identifiers under another id. Use `details.investorId`; correct it with `PUT` if the data changed |
| `409` | `external_key_party_id_conflict` | You already added this key party with different data. Use `details.keyPartyId`; correct it with `PUT` |
| `409` | `tax_form_already_active` | The investor already has a tax form in force, and a W-9 sent as data does not replace it. `details.activeTaxForm` names it when it can be read: a `W-9` on an investor you sent no other W-9 is your earlier one, on file; keep its `taxFormId`. For any other form, or no `details`, contact Securitize support to correct or replace it |

### Wait, or finish a step first

| Status | `code` | What to do |
| - | - | - |
| `409` | `verification_in_review` | The investor or person is under review and cannot change now. Poll the status |
| `409` | `verification_already_settled` | It has a verdict and can never change. Read the verdict |
| `409` | `verification_not_initialised` | A registration for verification did not complete. Send that create again with the same body, then repeat the call |
| `422` | `investor_not_verified` | Whitelisting waits for the investor's `status` to be `verified` |
| `422` | `suitability_not_verified` | Whitelisting waits for the suitability (`us`) and, in the EEA, the DLT test (`eu`); `details.notVerified` names which |

### Stop and talk to Securitize

| Status | `code` | What to do |
| - | - | - |
| `403` | `token_not_enabled` | The token is not enabled for your account. Check `tokenAddress`, or ask Securitize to enable it |
| `409` | `email_conflict` | The email belongs to another investor at Securitize; emails are unique across Securitize. Use the investor's own address |
| `409` | `identity_conflict` | This data cannot be used to onboard the investor through the API. Do not retry the same data; contact support with the `correlationId` |
| `409` | `investor_not_eligible` | This investor cannot be onboarded through the API. Contact support with the `correlationId` |
| `409` | `investor_type_mismatch`, `verification_refused` | The call cannot be applied to this investor's verification as it stands. Contact support with the `correlationId` |
| `409` | `investor_not_owned` | Another party leads this investor's verification; see [limited access](https://developers.securitize.io/secid-api/identifiers.md#when-the-investor-already-exists) |
| `409` | `wallet_conflict` | The address belongs to another investor. Use another address |
| `422` | `country_not_allowed` | The token's issuer does not accept investors from this investor's country |

### Not found

| Status | `code` | Typical cause |
| - | - | - |
| `404` | `investor_not_found` | Wrong id, or the wrong *kind* of id: every path that names an investor takes Securitize's `investorId`, not your `externalInvestorId`. Also an investor of the other type on a typed route, or another partner's investor |
| `404` | `key_party_not_found` | Wrong `keyPartyId`, or a corporate shareholder addressed through a person route (or the other way round) |
| `404` | `route_not_found` | No operation matches the method and path, for example a typo in the path or a `PATCH`. The message repeats the method and path received; check them against the API reference |

### Retry

| Status | `code` | What to do |
| - | - | - |
| `500` | `internal_error` | Retry with backoff; if it persists, contact support with the `correlationId`. The message is always generic |
| `502` | `verification_handoff_failed` | Send the same request again: a create with the same body finishes its registration; a document is kept and can be sent again; a submit is safe to repeat |
| `502` | `file_upload_failed` | Nothing was stored. Send the document or the tax form again |
| `502` | `tax_form_registration_failed` | On a PDF upload, nothing was attached: send the same request again. On a W-9 sent as data, the form may be on file: send it again, and a `409 tax_form_already_active` naming a `W-9` means it is |
| `502` | `document_outcome_unknown` | The document was kept; whether verification accepted it is not known. Sending it again is safe |
| `502` | `whitelist_unavailable` | The whitelisting could not be completed or read. Repeat the same request |

## What is safe to retry

| Call | Repeat with the same request |
| - | - |
| Create an investor or a key party | Safe. Answers `201` with the same id, and finishes a registration a `502` left incomplete |
| Correct (`PUT`) | Safe before the record locks |
| Upload a document | Stores another copy. Repeat only after a refusal or a `502` (see [Documents](https://developers.securitize.io/secid-api/documents.md#uploading-again)) |
| Submit | Safe. Anything already handed over comes back `skipped` |
| Register a wallet, whitelist a wallet | Safe. Answers `201` with the current state |
| Send the suitability | Safe. Once complete, it changes nothing |
| Send the compact suitability | Safe. Once `verified`, it changes nothing; a repeat of a `processing` or `rejected` one answers `422` with the reason |
| Upload a tax form PDF | Safe. Each upload replaces the form on file with the one you send |
| Send a W-9 as data | Safe. A repeat after a timeout or a `502` answers `201`, or `409 tax_form_already_active` naming the W-9 the first attempt put on file: treat that as done |
| Send a DLT test attempt | **Not safe.** Each failed attempt uses one of three |

Keep a small state per investor in your own system (created, documented, submitted, verdict) and resume from it after a crash, instead of replaying the whole flow.

## A handling pattern

```javascript theme={null}
const res = await fetch(url, { method, headers, body });
if (res.ok) return { status: res.status, body: await res.json() };   // 200 can mean "already exists": check the status

const body = await res.json().catch(() => ({}));
if (!body.code) throw new GatewayError(res.status, body.message ?? body.error);   // never reached the API: a 401 needs a new token; otherwise check the path

log.warn({ code: body.code, correlationId: body.correlationId });
switch (body.code) {
  case 'validation_error':
  case 'invalid_document':
    return fixAndResend(body.details);              // a bug in your mapping: fix every field listed
  case 'tax_form_type_not_allowed':
    return chooseAnotherForm(body.details.allowed); // the form does not fit the investor's type
  case 'tax_form_already_active':
    return keepOrEscalate(body.details?.activeTaxForm); // a W-9 you sent is on file; any other form goes to support
  case 'route_not_found':
    throw new Error(body.message);                  // a bug in your client: the method or path does not exist
  case 'external_investor_id_conflict':
  case 'external_key_party_id_conflict':
    return useExisting(body.details);               // the record exists: store its id
  case 'verification_in_review':
    return pollLater();                             // wait for the verdict
  case 'internal_error':
  case 'verification_handoff_failed':
  case 'file_upload_failed':
  case 'document_outcome_unknown':
  case 'whitelist_unavailable':
  case 'tax_form_registration_failed':
    return retryWithBackoff();
  default:
    return escalate(body.code, body.correlationId); // identity_conflict, investor_not_eligible, verification_refused…
}
```

## Getting help

When you contact Securitize support about a response, send:

* the `correlationId` (a gateway error has none: say so);
* your `externalInvestorId`, and the `investorId` or `keyPartyId` if you have them;
* the method and path, and whether it was sandbox or production;
* the `statusCode` and `code` you received, and what you expected instead.

Never send your client secret, an access token, a document's base64 content or an investor's personal data. The `correlationId` is enough for Securitize to find the request.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.