Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.zestequity.com/llms.txt

Use this file to discover all available pages before exploring further.

Partner-managed investor onboarding is the default in the Zest Partner 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

POST /v1/investors
Body:
{
  "investors": [
    {
      "partnerInvestorId": "acme-inv-7341",
      "email": "alice@example.com",
      "firstName": "Alice",
      "lastName": "Liddell",
      "dateOfBirth": "1985-04-12",
      "countryOfResidence": "AE"
    },
    {
      "partnerInvestorId": "acme-inv-7342",
      "email": "bob@example.com",
      "firstName": "Bob",
      "lastName": "Marley",
      "dateOfBirth": "1980-02-06",
      "countryOfResidence": "JM"
    }
  ]
}

What gets created server-side

For each created row, Zest persists two rows server-side, written sequentially within the same request (not wrapped in a single transaction — a created response means both writes succeeded):
CollectionRoleNotes
HoldCo PeopleIdentity record. Holds PII, kycManagedBy: "partner", and the (client_id, partnerInvestorId) mapping.Returned as zestPersonId in the row response.
DIFC UserPlatform-side account paired to the Person. Carries partner-onboarded defaults (email_is_verified: true, OTP method email, deterministic placeholder phone).Internal — used by subscription, signed-form, and funding endpoints to resolve the investor by personId.
This pairing is why the downstream POST /v1/spvs/.../subscriptions and the two upload endpoints accept personId directly: the User row is what they look up. If you ever see 404 not_found on a subscription create for a zestPersonId returned by POST /v1/investors, treat it as a server-side bug and surface the errorId to Zest support — never retry the investor create as a workaround.

Partial-success semantics

The endpoint always returns 200 OK with a per-row result envelope. A single bad row never fails the batch.
{
  "results": [
    {
      "partnerInvestorId": "acme-inv-7341",
      "status": "created",
      "zestPersonId": "psn_abc123"
    },
    {
      "partnerInvestorId": "acme-inv-7342",
      "status": "failed",
      "error": {
        "code": "format",
        "field": "dateOfBirth",
        "message": "dateOfBirth must be YYYY-MM-DD",
        "retryable": false
      }
    },
    {
      "partnerInvestorId": "acme-inv-7341",
      "status": "already_exists",
      "zestPersonId": "psn_abc123"
    }
  ]
}
Row statusMeaningAction
createdNew person row inserted.Persist the returned zestPersonId. Webhook investor.created will fire.
already_existsReplay of a previously-created partnerInvestorId.Use the returned zestPersonId; no webhook fires.
failedRow-level validation error.Inspect error.code + error.retryable. Fix and resubmit.

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 dedup’d server-side.

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 the same payload as the row response, plus kycManagedBy: "partner" for downstream consumers. already_exists and failed rows do not fire webhooks — replays are silent on the wire.

Failure cases

ScenarioBehaviour
partnerInvestorId repeated within the same batchFirst wins; subsequent rows fail with error.code = "duplicate" row-level.
Email collision with a different partnerInvestorIdRow fails with error.code = "conflict", retryable=false. Resolve in your system.
Tenant not provisioned403 forbidden at the request level (no row results).
Token invalid401 invalid_token at the request level.

Date and country formats

  • dateOfBirth: ISO date YYYY-MM-DD. Format failures return error.code = "format".
  • countryOfResidence: ISO 3166 alpha-2. Optional. Country code mismatches against Zest’s risk-rating list propagate to KYC-stage flagging but do not fail the create.