> ## 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).

# Send a tax form

> Send an investor's tax form so Securitize has it on file for withholding: a signed W-9, W-8BEN or W-8BEN-E as a PDF, or the fields of a W-9 and the investor's certification as JSON.

Securitize withholds tax on an investor's distributions according to the tax form it holds for them. Your investors complete that form with you: send it to Securitize, and it is on file for the investor, recorded as sent by you.

```
POST /investors/{investorId}/tax-forms      send the signed form, as a PDF
POST /investors/{investorId}/tax-forms/w9   send a W-9's fields, as JSON
```

If your investors fill in their W-9 in your app rather than sign a PDF, send its fields as JSON: Securitize generates the form's PDF. The W-8BEN and W-8BEN-E are sent as signed PDFs only.

The examples use `$BASE` for `https://partners-api.sandbox.securitize.io/investors/v1/onboarding-reliance` and `$ACCESS_TOKEN` for a bearer token ([get one](https://developers.securitize.io/secid-api/sandbox-and-auth.md#get-a-token)).

> [!NOTE]
>
> A tax form is not a verification document. Identity and company documents go to the [document routes](https://developers.securitize.io/secid-api/documents.md), as base64 JSON or `multipart/form-data`; a tax form goes to the routes on this page: a signed PDF as a file, or a W-9's fields as JSON.

## Which form

| Investor | US person | Form |
| - | - | - |
| Individual | Yes | `W-9` |
| Individual | No | `W-8BEN` |
| Entity | Yes | `W-9` |
| Entity | No | `W-8BEN-E` |

You choose the form from what you know about the investor. Securitize checks only that it fits the investor's type: a `W-8BEN-E` for an individual, or a `W-8BEN` for an entity, answers `422 tax_form_type_not_allowed`, and `details.allowed` lists the forms the investor can receive.

## Before you send it

| Requirement | If not |
| - | - |
| You lead the investor's verification: your create answered `201` | `409 investor_not_owned`. See [limited access](https://developers.securitize.io/secid-api/identifiers.md#when-the-investor-already-exists) |

The investor does not need to be `verified`: send the form when the investor completes it, at any point of the verification.

## Send a signed PDF

The body is `multipart/form-data`, the format of a web form with a file field, in three parts:

| Part | Content |
| - | - |
| `file` | The signed form, as a PDF |
| `formType` | `W-9`, `W-8BEN` or `W-8BEN-E` |
| `signatureDate` | The date the investor signed it, `YYYY-MM-DD` |

Let your HTTP client build the body. It writes the `content-type` header itself, with the boundary that separates the parts: do not set `content-type` yourself (a header without that boundary answers `400` with `Multipart: Boundary not found`), and do not base64-encode the file.

```bash curl theme={null}
curl -X POST "$BASE/investors/$INVESTOR_ID/tax-forms" -H "Authorization: Bearer $ACCESS_TOKEN" \
  -F formType=W-9 \
  -F signatureDate=2026-10-20 \
  -F "file=@w9-signed.pdf;type=application/pdf"
```

```javascript Node.js theme={null}
import { readFile } from 'node:fs/promises';

const form = new FormData();
form.append('formType', 'W-9');
form.append('signatureDate', '2026-10-20');
form.append('file', new Blob([await readFile('w9-signed.pdf')], { type: 'application/pdf' }), 'w9-signed.pdf');

// No content-type header: fetch sets it, with the boundary.
const res = await fetch(`${process.env.BASE}/investors/${investorId}/tax-forms`, {
  method: 'POST',
  headers: { authorization: `Bearer ${process.env.ACCESS_TOKEN}` },
  body: form,
});
```

```python Python theme={null}
import os
import requests

with open("w9-signed.pdf", "rb") as pdf:
    res = requests.post(
        f"{os.environ['BASE']}/investors/{investor_id}/tax-forms",
        headers={"Authorization": f"Bearer {os.environ['ACCESS_TOKEN']}"},
        data={"formType": "W-9", "signatureDate": "2026-10-20"},
        files={"file": ("w9-signed.pdf", pdf, "application/pdf")},
    )
```

```json theme={null}
{
  "taxFormId": "b68db8ed-8424-4d54-99ed-34c0a3cb826c",
  "formType": "W-9",
  "submittedAt": "2026-10-20T17:19:50.730Z"
}
```

`201` means the form is on file for the investor. The answer is a receipt: there is no call to read or list an investor's tax forms. Each upload answers with a new `taxFormId`, even for the same form: keep the latest, and quote it to Securitize support about this form.

## The signature date of a PDF

`signatureDate` is the date the investor signed the form, as `YYYY-MM-DD`. It can be at most one day after today in UTC, so a form signed today is accepted in any time zone; a later date answers `400 validation_error`.

For a `W-8BEN` or `W-8BEN-E`, it also sets when the form expires: on December 31 of the third year after the year it was signed. A form signed on 2026-10-20 is valid until 2029-12-31. The API does not tell you when a form expires: keep the date with the investor in your system, and send a newly signed form before it. A W-8 signed so long ago that this date has passed is accepted, but it is out of date: ask the investor to sign a new one. A `W-9` has no expiry date.

## PDF file rules

* **One PDF per call, in the part named `file`.** A second file answers `400` with the message `Too many files`; a file in a part with another name answers `400` with `Unexpected field`.
* **At most 10 MB** (10,485,760 bytes). A larger file answers `413 payload_too_large`, but only once it has been uploaded: check the size before sending.
* **The file is judged by its first bytes.** Its name and declared type do not matter, so an image renamed `.pdf` answers `415 mime_not_supported`. Convert a scan to PDF before sending it.
* **The PDF is kept exactly as you send it.** Send the complete signed form, every page.
* In sandbox, any PDF of at most 10 MB works as a test file, such as a page printed to PDF. Never send a real investor's form there.

## Send a W-9 as data

The body is the W-9's fields, as JSON, each named after its line on the form; the form type is implied by the route. The TIN is 9 digits without dashes, as an `SSN` (also for an ITIN) or an `EIN`; a corporation, a partnership or an LLC taxed as either sends an `EIN`. Every field, its rules and its values are in the [`W9Data` reference](https://developers.securitize.io/api-reference/tax-forms/send-an-investors-w-9-as-data).

| W-9 line | Field |
| - | - |
| 1 | `formInvestorName` |
| 2 | `businessName` |
| 3a | `formClassification` |
| 4 | `exemptPayeeCodes`, `exemptFATCAReportingCodes` |
| 5 | `address1`, `address2` |
| 6 | `city`, `region`, `postalCode`, `country` |
| Part I | `taxPayerIdType`, `taxPayerIdNumber` |
| — | `taxCountry`: `US`, or the US territory the investor is a US person of |
| Part II | `certification`: the investor's certification. See [The certification](#the-certification) |

```bash curl theme={null}
curl -X POST "$BASE/investors/$INVESTOR_ID/tax-forms/w9" -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "content-type: application/json" \
  -d '{
    "formInvestorName": "Jane Test",
    "address1": "1007 Liberty Street",
    "city": "Meadville",
    "region": "PA",
    "postalCode": "16335",
    "country": "US",
    "formClassification": "INDIVIDUAL",
    "taxPayerIdType": "SSN",
    "taxPayerIdNumber": "123456789",
    "taxCountry": "US",
    "certification": {
      "certifiedAt": "2026-10-28T15:28:40Z",
      "signatureName": "Jane Test",
      "subjectToBackupWithholding": false,
      "isUSPerson": true,
      "certificationTextVersion": "w9-sm-2026-10"
    }
  }'
```

```javascript Node.js theme={null}
const res = await fetch(`${process.env.BASE}/investors/${investorId}/tax-forms/w9`, {
  method: 'POST',
  headers: { authorization: `Bearer ${process.env.ACCESS_TOKEN}`, 'content-type': 'application/json' },
  body: JSON.stringify({
    formInvestorName: 'Jane Test',
    address1: '1007 Liberty Street',
    city: 'Meadville',
    region: 'PA',
    postalCode: '16335',
    country: 'US',
    formClassification: 'INDIVIDUAL',
    taxPayerIdType: 'SSN',
    taxPayerIdNumber: '123456789',
    taxCountry: 'US',
    certification: {
      certifiedAt: '2026-10-28T15:28:40Z',
      signatureName: 'Jane Test',
      subjectToBackupWithholding: false,
      isUSPerson: true,
      certificationTextVersion: 'w9-sm-2026-10',
    },
  }),
});
```

```python Python theme={null}
import os
import requests

res = requests.post(
    f"{os.environ['BASE']}/investors/{investor_id}/tax-forms/w9",
    headers={"Authorization": f"Bearer {os.environ['ACCESS_TOKEN']}"},
    json={
        "formInvestorName": "Jane Test",
        "address1": "1007 Liberty Street",
        "city": "Meadville",
        "region": "PA",
        "postalCode": "16335",
        "country": "US",
        "formClassification": "INDIVIDUAL",
        "taxPayerIdType": "SSN",
        "taxPayerIdNumber": "123456789",
        "taxCountry": "US",
        "certification": {
            "certifiedAt": "2026-10-28T15:28:40Z",
            "signatureName": "Jane Test",
            "subjectToBackupWithholding": False,
            "isUSPerson": True,
            "certificationTextVersion": "w9-sm-2026-10",
        },
    },
)
```

```json theme={null}
{
  "taxFormId": "5c2fe256-5474-4f5f-8d02-595b058ae698",
  "formType": "W-9",
  "submittedAt": "2026-10-22T17:57:47.187Z"
}
```

`201` means the W-9 is on file for the investor; keep its `taxFormId` to quote to Securitize support.

* **The generated PDF is signed with the line 1 name and dated the day Securitize receives the form.** Send the W-9 when the investor completes it.
* **A W-9 sent as data does not replace a form in force.** While the investor has one on file (valid, or pending review), the call answers `409 tax_form_already_active`, and `details.activeTaxForm` names it (`taxFormId`, `formType`) when it can be read. To correct or replace it, contact Securitize support with that `taxFormId`.

## The certification

Before you send a W-9 as data, the investor certifies it in your flow, under penalties of perjury: show them the certification text below, have them agree to it, and send what they agreed to in `certification`. A W-9 without it answers `400 validation_error`.

### Certification text `w9-sm-2026-10`

Show the investor, in this order: who the certification is made to, the name, address and TIN they entered (the TIN masked, with a way to correct any of them), the certification, a box to certify it, the instructions, and the button that signs.

> Requester: **Securitize Markets, LLC** (FINRA-registered broker-dealer). This certification is made to Securitize Markets to open your brokerage account.
>
> Under penalties of perjury, I certify that:
>
> 1. The social security number (SSN) shown above is my correct taxpayer identification number.
> 2. I am not subject to backup withholding because:
>    1. I have **not** been notified by the IRS that I am subject to backup withholding as a result of a failure to report all interest or dividends, or
>    2. the IRS has notified me that I am no longer subject to backup withholding.
> 3. I am a US citizen or permanent resident or other US person for tax purposes.
> 4. The FATCA code(s) entered on this form (if any) indicating that I am exempt from FATCA reporting is correct. (Note: We are legally required to present this here, but it is not applicable to Securitize Markets customers because Securitize Markets accounts are US-based.)
>
> ☑ **I certify the above to be true**
>
> **Certification instructions**
>
> Do not uncheck the box above unless you've been notified by the IRS that you are subject to backup withholding. This means that you have failed to report all interest and dividends on your tax return. If you were notified, you would've received a C-notice from the IRS.
>
> If your name, address or SSN is incorrect, please go back and correct it before continuing.
>
> By tapping **Agree**, you are electronically signing a Form W-9 that Securitize Markets, LLC will generate on your behalf from the information shown. Your consent and the date and time of your signature will be recorded.
>
> **The Internal Revenue Service does not require your consent to any provision of this document other than the certifications required to avoid backup withholding.**

Show the text as published, without changes, and send its version in `certificationTextVersion`. The box starts checked; an investor who unchecks it is subject to backup withholding (`subjectToBackupWithholding: true`). Record when the investor taps **Agree** as `certifiedAt`.

| Field | What to send |
| - | - |
| `certifiedAt` | When the investor agreed, as an ISO 8601 date-time with seconds and an offset: `2026-10-28T15:28:40Z` or `2026-10-28T11:28:40-04:00`. Not in the future, give or take 5 minutes of clock difference |
| `signatureName` | The name the investor signs with, up to 100 characters: the name shown above the certification. For an entity, the person who signs on its behalf |
| `subjectToBackupWithholding` | `true` if the investor unchecked the box because the IRS notified them they are subject to backup withholding (item 2 does not apply to them) |
| `isUSPerson` | The investor certified being a U.S. person (item 3). Must be `true` |
| `certificationTextVersion` | The version of the text the investor was shown: `w9-sm-2026-10` |

**An investor subject to backup withholding cannot send the W-9 as data.** `subjectToBackupWithholding: true` answers `422 business_rule_violation` with `details.reason` `backup_withholding_not_supported`, and nothing is kept. Contact Securitize support for that investor.

## One form on file

An investor has one tax form on file. Each PDF upload replaces the one before it, whatever its type: when an investor who sent a `W-8BEN` becomes a US person, their new `W-9` replaces it. Sending the same PDF again is safe; it replaces the copy on file with the same form.

A W-9 sent as data is put on file only while the investor has no form in force: otherwise it answers `409 tax_form_already_active` and the form on file stays. An expired or rejected form does not count.

## When it fails

A refused request (`400`, `404`, `409 investor_not_owned`, `415`, `422`) keeps nothing: correct it and send it again. A `404 investor_not_found` usually means your `externalInvestorId` went where Securitize's `investorId` belongs.

* **A `502` on a PDF upload** (`file_upload_failed`, `tax_form_registration_failed`) means nothing was attached to the investor: send the same request again.
* **A `502` or a timeout on a W-9 sent as data** means the form may or may not be on file: send the same request again. A `201` puts it on file; a `409 tax_form_already_active` whose `details.activeTaxForm.formType` is `W-9`, for an investor you sent no other W-9, means the first attempt is on file: keep that `taxFormId`.

Every field, response and example is in the API reference: [upload a signed PDF](https://developers.securitize.io/api-reference/tax-forms/upload-an-investors-signed-tax-form.md) and [send a W-9 as data](https://developers.securitize.io/api-reference/tax-forms/send-an-investors-w-9-as-data.md).


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