Skip to main content
Partner-managed investor onboarding is the default in the Zest Public API: you run KYC on your side and POST the resulting investor records to Zest. Zest persists each row as a Person and returns a stable zestPersonId for use in subscription flows.

Bulk import endpoint

The endpoint accepts an array of individual-investor records. Institutional/entity investors are not supported on this endpoint in v1 — those will surface in a follow-up endpoint.

Request body

On success, the response returns zestPersonId per row. Use it on subsequent POST /v1/spvs/{slug}/subscriptions, POST .../forms, and POST .../fundings calls to identify the investor. If you ever see 404 not_found on a subscription create for a zestPersonId returned by POST /v1/investors, surface the errorId to Zest support — never retry the investor create as a workaround.

KYC documents

Zest needs a KYC evidence file for every investor before it accepts a subscription for them. Upload that evidence with this endpoint. It works only for investors you created through POST /v1/investors. The request is multipart/form-data.

Fields

Send any combination of these fields. At least one is required.

Rules

  • A request may send any subset of the fields. You can upload everything at once or in several requests.
  • Each request is stored separately. A later upload never overwrites earlier evidence, so to correct a document, upload it again.
  • Each file is at most 10 MB and must be PDF, JPEG, PNG or WEBP.
  • approvalComments is at most 5000 characters (after trimming); longer text returns 400 invalid_request.
  • approvalComments or approvalDocument satisfies the compliance-approval requirement. Sending both is fine.
  • Only documents uploaded through this endpoint by your application count toward the requirement.
  • An unknown zestPersonId, an investor created by another partner, and an investor you did not create all return the same 404 not_found.

Response

A successful request returns 201 Created. missingRequiredTypes lists what is still outstanding. Subscriptions for the investor are refused until it is empty.

Field reference

Control plane

Personal

Contact

residentialAddress (nested, required)

mailingAddress (nested, required)

Used for tax + subscription document delivery. Often identical to residentialAddress but separated for jurisdictions where they differ.

Tax

Compliance

Identification

Required-nullable convention

Three fields (hasBackupWithholding, qualifiedStatus, passportNumber) are required-nullable: the key MUST appear in your JSON body, but the value MAY be null. Omitting the key entirely returns 400 validation_error; sending null is valid.

Qualified status

qualifiedStatus accepts one of the following enum values, or null:
  • QUALIFIED_PURCHASER
  • QUALIFIED_CLIENT
  • TRUSTEE_QUALIFIED_PURCHASER
  • KNOWLEDGEABLE_EMPLOYEE
  • KNOWLEDGEABLE_EXECUTIVE
Any other string value returns 400 validation_error at the request level.

Partial-success semantics

The endpoint always returns 200 OK with a per-row result envelope. A single bad row never fails the batch (with the exception of request-level validation errors — see Failure cases below).

Per-row error codes

Correlation: partnerInvestorId

partnerInvestorId is the stable join key between your system and Zest. It must be:
  • Unique within your tenant.
  • Present on every row (no nulls).
  • Stable across retries.
Zest persists the (client_id, partnerInvestorId) -> zestPersonId mapping. Replaying the same id always returns the existing zestPersonId, never a fresh one. This makes the endpoint safe to retry under any failure mode — duplicates are deduplicated.

Idempotency layers

There are two independent layers of replay protection:
  1. Row-level dedup via partnerInvestorId (always on).
  2. Response caching via Idempotency-Key header (24h TTL, optional).
For long-running bulk imports, layer (1) is sufficient — even if your client crashes mid-batch and you retry from scratch, all created rows return already_exists on the second pass. For interactive single-row creates, add Idempotency-Key so the cached response is served byte-for-byte on retry.

Webhook fan-out

Each created row fires one investor.created webhook with a minimal payload — only the six fields needed for correlation:
Sensitive fields you sent in the create (such as taxId, passportNumber, addresses) do not appear in the webhook payload — they stay inside Zest. If you need the full investor record later, request a fetch endpoint (currently a deferred follow-up; raise with Zest support). already_exists and failed rows do not fire webhooks — replays are silent on the wire.

Failure cases