> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mf-atlas.space/llms.txt
> Use this file to discover all available pages before exploring further.

# MF API INTEGRATION GUIDE

# Mutual Fund API — Integration Guide (for AI coding agents)

This document is written to be handed directly to an AI coding agent that will
implement a client integration against the Mutual Fund API. It describes the
**end-to-end flow**, the exact request/response shapes, and — most
importantly — the **asynchronous / multi-step nuances** that are easy to get
wrong if you assume any of these calls complete synchronously.

If you are the implementing agent: read the whole document before writing
code. The three flows in "Critical async nuances" below are the ones that
cause the most integration bugs — do not skip them.

## Documentation & spec links

This guide is a summary — use these as the source of truth for anything not
covered here, or if a field/status looks like it might have changed since
this was written:

| Resource | Link | Use it for |
| - | - | - |
| Full documentation site | `https://docs.mf-atlas.space/` | Prose explanations, field-by-field tables, Mermaid sequence/state diagrams per resource. Start at `/api-documentation/prerequisites`. |
| **OpenAPI spec** | `https://docs.mf-atlas.space/openapi.yaml` | Machine-readable schema for every customer-facing endpoint — feed this directly to an AI agent or a codegen tool (e.g. `openapi-generator`, `orval`) to generate typed request/response models and a client SDK instead of hand-transcribing shapes from this document. |
| Webhooks reference | `https://docs.mf-atlas.space/api-documentation/webhooks` | Full event catalog, signature verification, retry/backoff schedule — §5 below is a summary of this page. |
| Demo application | `https://github.com/anvitra-ai/demo-mf-app` | A working reference client that consumes these APIs end to end — use it to see the full investor → investment account → mandate → order → payment flow implemented in real code, if the summary below leaves anything ambiguous. |

<sub>This is the documentation host, not the API host — it doesn't take your
bearer token. Your actual `POST /api/auth/v1/token` calls go to the
sandbox/production API base URL your onboarding contact gave you (see §0
below), which is a separate host from the docs site above.</sub>

***

## 0. Conventions that apply to every call

* **Base URL**: `https://<env-host>/api/<resource>/v1` — each resource group
  is versioned independently (`/api/investors/v1`, `/api/orders/v1`, etc).
* **Auth**: `POST /api/auth/v1/token` with `{ client_id, client_secret }` →
  `access_token` (Bearer), valid for `expires_in` seconds. No refresh token —
  request a new one before expiry. Send it as `Authorization: Bearer <token>`
  on every other call.
* **Response envelope**:
  * Success: `{ "success": true, "data": { ... }, "message": "" }`
  * Error: `{ "success": false, "error": { "code": "...", "message": "...", "details": [...], "provider_remark": "...", "request_id": "..." } }`
  * **Branch on `error.code`, never on `error.message` or `provider_remark`** —
    those are human text and can change without notice.
* **Idempotency**: send `Idempotency-Key: <uuid>` on write calls, required in
  practice on `POST /api/orders/v1/`. Replaying the same key + body returns
  the original response with `Idempotency-Replayed: true`; same key with a
  different body → `409 IDEMPOTENCY_KEY_REUSED`.
* **Pagination**: list endpoints take `?limit=&cursor=`. The full response
  body is `{ "success": true, "data": [ ... ], "next_cursor": "..." }` —
  `next_cursor` is a top-level sibling of `data`, not nested inside it, and
  `data` is the array itself (not `{ data: [...], next_cursor }` wrapped a
  second time). No `next_cursor` = last page.
* **PII**: `GET` responses always mask PAN/bank account numbers. See the PII
  masking doc if you need to reveal one field for support/verification UI.
* **IDs are friendly, not UUIDs**: `I_SI_IND_000042` (investor),
  `A_RT_000001` (investment account), `bnk_...` (bank account), `mnd_...`
  (mandate), `ord_...` (order), `pay_...` (payment). Store these as your
  foreign keys — never store or depend on the exchange's own internal codes.

## 1. High-level flow

```
1. Get a token                          (sync)
2. Create investor                      (ASYNC — see §2)
3. Create investment account            (sync, needs investor to exist — not necessarily ACTIVE)
4. Create mandate (optional, for auto-debit)  (needs verification — see §3)
5. Create purchase order                (ASYNC dispatch — see §4)
6. Create payment for the order         (mode-dependent settlement — see §4)
7. Poll or (preferably) receive webhooks for every async step above
```

Steps 2–6 each return **before** the underlying work is finished. Your
integration must be built around polling and/or webhooks from day one — do
not design it as if any of these calls are synchronous request/response with
a final result in the HTTP response body.

***

## 2. Critical nuance #1 — Investor creation is asynchronous, and ends with a link the investor must open

`POST /api/investors/v1/` returns `201` **immediately** with
`status: "PENDING"`. Registering the Unique Client Code (UCC) with the
exchange happens in the background. **You cannot place an order for this
investor yet** — an investor must reach `status: "ACTIVE"` first
(`REGISTERED` is not enough: the investor must also complete the exchange's
client authorisation — see §2.3).

### 2.0 Check KYC first — and branch on `kyc_status`

The PAN must be KYC-compliant with a KRA before the investor can transact.
Check it **before** creating the investor:

```
GET /api/kyc/v1/pan-check?pan=ABCPK1234Q
→ { "pan": "AB******4Q", "name": "…", "status_date": "…", "entry_date": "…",
    "modification_date": "…", "kyc_status": "KYC_VALIDATED",
    "kyc_status_remark": "…", "kra_name": "CVLKRA" }
```

After the investor exists, `GET /api/investors/v1/:id/kyc-status` re-checks
the same PAN and returns the **same `kyc_status` values**:

```
→ { "pan": "AB******4Q", "kyc_status": "KYC_VALIDATED", "holding_type": "SINGLE",
    "holder_name": "…", "holder_dob": "", "remark": "" }
```

| `kyc_status` | Compliant? | Action |
| - | - | - |
| `KYC_VALIDATED` | Yes | Proceed. |
| `KYC_REGISTERED` | Yes | Proceed. |
| `KYC_UNDER_PROCESS` | No | KRA still processing — re-check later. |
| `KYC_ON_HOLD` | No | Show remark; investor fixes KYC with the KRA. |
| `KYC_REJECTED` | No | Show remark; investor redoes KYC. |
| `KYC_NOT_FOUND` | No | No KYC record — start eKYC (below). |
| `KYC_UNKNOWN` | No | Unclassified KRA status — treat as not ready, re-check later. |

* **Only `KYC_VALIDATED` / `KYC_REGISTERED` are compliant.** Send any value
  not in this table down the `KYC_UNKNOWN` path — the list may grow. Note:
  `KYC_VALIDATED` and `KYC_REGISTERED` are both compliant for
  onboarding purposes.
* Branch on `kyc_status` only. `kyc_status_remark` / `remark` are free text
  for display; never parse them.
* A lookup that can't complete returns **`502 PROVIDER_UNAVAILABLE`** (retry)
  from both endpoints — never a `kyc_status`. Missing `pan` → `400
  VALIDATION_FAILED`; unknown investor id → `404 NOT_FOUND`.
* `pan-check`'s `name`, dates (KRA's own format, not ISO 8601),
  `kyc_status_remark` and `kra_name` come straight from the KRA record and can
  be blank. `kyc-status`'s `holder_dob` is no longer populated.
* **Sandbox `pan-check` is a stub:** every PAN returns `KYC_VALIDATED`, except
  the reserved PAN `ABCDE1234F`, which returns `KYC_NOT_FOUND`. Only `pan`,
  `kyc_status` and a `Demo mode: …` remark are returned.
* In most cases `kyc_status_remark`/`remark` is specific enough to tell the
  investor what to fix — it comes straight from the KRA, so wording varies
  by KRA and can be blank even for a non-compliant status.

**Deciding whether to start a fresh eKYC** — branch on `kyc_status` alone,
never on "non-compliant" generically:

| `kyc_status` | Start fresh eKYC? |
| - | - |
| `KYC_NOT_FOUND` | Yes — no KYC record exists. |
| `KYC_REJECTED` | Yes, once the investor has addressed the remark. |
| `KYC_UNDER_PROCESS` | **No** — an eKYC is already in flight with the KRA; resubmitting doesn't speed it up. Poll `kyc-status` again later. |
| `KYC_ON_HOLD` | No — needs the investor to resolve something with the KRA directly. |
| `KYC_UNKNOWN` | No — treat as not ready; re-check later. |

`amc_code` for eKYC is a small, fixed vocabulary specific to fresh KYC
registration — not the scheme-master `amc_code` from
`GET /api/schemes/v1/amcs`. Fetch the supported list from
`GET /api/investors/v1/ekyc/amcs` → `{ "amc_code": "H", "amc_name": "HDFC AMC" }, ...`
and use one of those codes below.

Fresh eKYC for an existing investor whose PAN is `KYC_NOT_FOUND` (or
`KYC_REJECTED` once addressed — see the decision table above):

```
POST /api/investors/v1/:id/ekyc   { "amc_code": "H" }
→ { "verification_url": "https://…", "message": "…" }
```

Deliver `verification_url` to the investor (you send it, not us), then
re-check until `kyc_status` is `KYC_VALIDATED` or `KYC_REGISTERED`.

**No investor yet?** Start eKYC from a bare PAN:
`POST /api/kyc/v1/registration` with
`{ "pan", "mobile" (10 digits), "email", "amc_code" }` →
`{ "verification_url", "message" }`; or `POST /api/kyc/v1/registration-link`
(same body + optional `secret_code`) → `{ link_id, url }`, resolved by the same
public `kyc-links` routes below (preview `investor_name` is empty). Bad
`pan`/`mobile`/`email`/`amc_code` → `400 VALIDATION_FAILED`.

**Public fresh-KYC-registration link (optional self-serve flow):** instead
of calling `/ekyc` and delivering `verification_url` yourself,
`POST /api/investors/v1/:id/kyc-registration-link`
(body `{ "amc_code": "H", "secret_code": "1234" }`, `secret_code` optional)
returns `{ link_id, url }` where `url` already points at
`GET /api/public/v1/kyc-links/:link_id/redirect`. Share `url` directly with
the investor — opening it (append `?secret=1234` if a PIN was set) calls
eKYC for them and 302-redirects straight to the exchange's verification
page; unlike every other public link in this API, the redirect route
returns an HTTP redirect, not JSON. To show context (investor name, masked
PAN, AMC code) before sending the investor on — e.g. from your own
PIN-gated confirmation page — call `GET /api/public/v1/kyc-links/:link_id`
first (PIN via the `X-Draft-Secret` header there, not the query string),
then navigate to the `/redirect` URL. Expires after 7 days, same
`401`/`410` semantics as §3.4/§4.4. `GET /api/investors/v1/:id/kyc-registration-links`
lists every link generated for the investor (newest first: `link_id`, `url`,
`amc_code`, `pin_protected`, `status` `ACTIVE`|`EXPIRED`, `created_at`,
`expires_at`; the PIN is never returned) — use it for an investor-page KYC section.

### 2.1 Lifecycle

```
DRAFT --(submit:true)--> PENDING --> PROCESSING --> REGISTERED --(client authorised)--> ACTIVE
                                            \--> REJECTED --(fix + resubmit)--> PENDING
                                            \--> FATCA_PENDING --(PATCH / retry, FATCA accepted)--> REGISTERED
```

* `FATCA_PENDING` — UCC registered but NSE rejected the FATCA declaration
  (reason in `provider_remark`). Editable; orders are blocked until it becomes
  `REGISTERED` and then `ACTIVE`. NRI investors must send `fatca.tin`.

* `DRAFT` — you passed `"submit": false` (e.g. a form still being filled in).
  Nothing sent to the exchange.
  (You can securely share a draft — or a `FATCA_PENDING` investor needing FATCA corrections — with the end-user by generating a Public Onboarding Link via `POST /api/investors/v1/:id/public-link`).

* `PENDING` — queued for the exchange.

* `PROCESSING` — exchange call in flight.

* `REGISTERED` — UCC registered (and FATCA accepted), but the investor has
  **not yet completed the exchange's client authorisation**. Orders are still
  rejected (`422 KYC_INCOMPLETE`). `GET` carries a `client_authorization`
  object explaining what is outstanding (§2.3).

* `ACTIVE` — registered **and** client-authorised. Terminal-success state;
  orders are now possible. We poll the exchange about every 30 minutes during
  exchange hours and flip `REGISTERED` → `ACTIVE`.

* `REJECTED` — **not discarded**. Not terminal. Fix the data and resubmit.
  Reason is in `provider_remark` / the last `provider_steps` entry.

### 2.2 How to find out registration finished

Two mechanisms — implement both, webhook first, poll as a fallback:

1. **Webhook** (preferred): `investor.registered` / `investor.activated` /
   `investor.rejected`. Treat **`investor.activated`** (status `ACTIVE`) as
   "ready to transact", not `investor.registered`.
2. **Poll**: `GET /api/investors/v1/:id`, inspect `data.status`.

Do not assume `REGISTERED` or `ACTIVE` arrives within any fixed time window — build this
as an event-driven state transition in your own data model, not a blocking
wait after the `POST`.

### 2.3 The confirmation link (the actual "link submission" nuance)

Once UCC registration succeeds, **NSE emails/SMSes the investor a
confirmation link directly** — this happens outside your system, you do not
control it, and you cannot skip it. The investor must open that link and
complete whatever confirmation NSE requires there. **That confirmation is the
client authorisation, and it is what moves the investor from `REGISTERED` to
`ACTIVE`.**

While it is outstanding, `GET /api/investors/v1/:id` returns a
`client_authorization` object (present after our first check, at most \~30
minutes after registration):

```json theme={null}
{ "data": { "id": "I_SI_IND_000042", "status": "REGISTERED",
            "client_authorization": {
              "status": "PENDING", "authorized": false, "email_sent": true,
              "fatca_exists": true, "aof_exists": true,
              "checked_at": "2026-09-06T10:30:00Z" } } }
```

* `status` is NSE's own wording (`SUCCESS` once done), or `NOT_FOUND` if NSE
  has no authorisation record for the UCC yet.
* `email_sent: false` means NSE has not sent the investor anything yet.
* `authorized_at` is filled once the investor completes it; `authorized: true`
  coincides with `status: "ACTIVE"`.
* `fatca_exists` / `aof_exists` (with `*_remark`) show whether NSE holds the
  FATCA declaration / account opening form, a common reason it stays pending.

If you'd rather surface that link inside your own product instead of relying
on the investor to find NSE's email/SMS, fetch it yourself:

```
GET /api/investors/v1/:id?auth_link=true
```

```json theme={null}
{ "data": { "id": "I_SI_IND_000042", "status": "REGISTERED",
            "auth_link": "https://nseinvest.com/mf/51ae3912" } }
```

Notes your implementation must respect:

* `auth_link` is fetched **live** from NSE on every call with the flag set —
  it is never stored on our side. Don't cache it beyond the current response.
* It is only populated while `status` is `REGISTERED` (an `ACTIVE` investor
  has already authorised). Calling with the flag at any other status simply
  omits the field — don't treat that as an error.
* Build a UI/notification step that surfaces this link to the investor (or
  confirms they received NSE's own email/SMS) as part of onboarding — this is
  a real manual step in the flow, not just an API nuance.

### 2.4 Rejection → retry

* If the **data was wrong**: `PATCH /api/investors/v1/:id` with corrected
  fields + `"submit": true` → re-queues as a fresh registration attempt.
* If the **data was fine** and the failure was transient (exchange
  temporarily unavailable): `POST /api/investors/v1/:id/retry` (no body) —
  re-sends the stored request as-is. `409` if already `REGISTERED` or `ACTIVE`.

### 2.5 Bank account status is a second, independent async status

Each `bank_accounts[]` entry has its own `status` (`ADDED` → `PENDING` →
whatever NSE settles on), tracked **separately** from the investor's overall
UCC `status`. An investor can be `REGISTERED` while a specific bank account is
still `PENDING` in NSE's back office. If your flow depends on a specific bank
account being usable (e.g. before creating a mandate against it), don't infer
that from investor `status` alone — check that account's own `status`, and
call `POST /api/investors/v1/sync` if you suspect it's stale.

### 2.6 `address.state` and `address.country` aren't free text at the exchange

`address.state` is accepted as free text on this API (e.g. `"Maharashtra"`)
but is translated internally to the exchange's own fixed State Master code
before UCC registration. **Anything that doesn't match an entry in that
table — a typo, an unsupported spelling, or a state the table has no entry
for at all (this is a known gap for Ladakh) — is silently mapped to
`OTHERS`, not rejected.** There is no validation error to catch this; the
investor still registers, just against the wrong state code. See
[Valid `state` values](https://docs.mf-atlas.space/api-documentation/create-investor#valid-state-values)
for the full accepted list, or fetch it programmatically from
[`GET /api/masters/v1/states`](https://docs.mf-atlas.space/api-documentation/masters#states)
— the masters endpoints also serve dropdown catalogs for every other coded
field on the investor request (tax statuses grouped for the resident/NRI
cascade, occupations, bank account types, nominee relationships, FATCA
country codes and enums — see
[Form Lookup Tables](https://docs.mf-atlas.space/api-documentation/masters.md)).
Render these as dropdowns rather than free-text inputs wherever you can.

`address.country` is not a real choice either — this address block is always
the investor's mandatory *domestic* Indian address, and it is always
registered with the exchange as India regardless of what you send. NRI
(`NRI_REPATRIABLE` / `NRI_NON_REPATRIABLE`) investors must additionally send
`foreign_address` (`line1`, `city`, `state`, `pincode`, and `country` as a
FATCA country code such as `US`); it is rejected locally with
`VALIDATION_FAILED` if missing, and ignored for other tax statuses.

### 2.7 Minimum implementation checklist for this step

* [ ] Run `pan-check` before creating the investor; proceed only on
  `KYC_VALIDATED`/`KYC_REGISTERED`, route `KYC_NOT_FOUND` to eKYC and
  every other value to a "KYC not ready" state showing the remark.
* [ ] Persist investor locally with your own status field mirroring ours.
* [ ] Subscribe to `investor.registered` / `investor.activated` /
  `investor.rejected` webhooks; unlock ordering on `investor.activated`.
* [ ] Fallback poll job for investors stuck `PENDING`/`PROCESSING` beyond a
  sane timeout (also covers lost webhook deliveries — or call
  `POST /api/investors/v1/sync` to reconcile against NSE directly).
* [ ] Surface the NSE confirmation link/email step to the investor as a
  required manual action before considering onboarding "done" — onboarding
  is done at `ACTIVE`, not `REGISTERED`. Show `client_authorization`
  while the investor is `REGISTERED` so support can see what is pending.
* [ ] Handle `REJECTED` as a correctable state in your UI, not a dead end.
* [ ] Validate `address.state` against the known-good list before sending —
  don't rely on the API to catch an unrecognized state name.
* [ ] For NRE/NRO investors, collect and send `foreign_address` (overseas
  address, `country` from the countries master) in addition to the
  domestic `address`.

***

## 3. Critical nuance #2 — Mandate creation needs investor confirmation before it's usable

A mandate authorizes the exchange to auto-debit an investor's bank account up
to a ceiling `amount`. Creating one is `POST /api/mandates/v1/`, but **the
mandate is not necessarily active the instant you create it** — it depends on
`type`:

| `type` | On create | Becomes usable when |
| - | - | - |
| `ENACH` | `REGISTERED` (or `REJECTED` if declined outright) | Immediately, once `REGISTERED` — **but** the bank still has to approve the eNACH registration; NSE also emails/SMSes the investor a confirmation link for this. |
| `PHYSICAL` | `PENDING` | You collect a signed paper form, scan it, and `POST /api/mandates/v1/:id/image`; on acceptance it moves to `REGISTERED`. |

### 3.1 The verification link, again

Just like investor UCC confirmation, mandate creation returns (best-effort,
may be absent) an `auth_link`:

```json theme={null}
{ "data": { "id": "mnd_4a1b2c", "status": "REGISTERED",
            "auth_link": "https://nseinvest.com/mf/51ae3912" } }
```

* Absent for `PHYSICAL` mandates, or if it just isn't ready yet at creation
  time.
* If missing, poll `GET /api/mandates/v1/:id` — it retries fetching the link
  while it's outstanding.
* Redirect the investor to this link so **they** can approve the eNACH
  registration with their bank — this is investor action you cannot automate
  away. Treat "mandate created" and "mandate approved by investor's bank" as
  two different milestones in your own UI/state machine.

### 3.2 Don't use a mandate until you've confirmed it's active

Before paying an order with a mandate, the mandate must be `REGISTERED` *and*
its ceiling must cover the payment. Two checks happen server-side at payment
time and will reject you if you skip client-side verification:

| Condition | Response |
| - | - |
| Mandate not `REGISTERED` | `422 MANDATE_NOT_ACTIVE` |
| Payment total exceeds mandate `amount` | `422 INSUFFICIENT_MANDATE_LIMIT` |
| Mandate belongs to a different investor | `400 VALIDATION_FAILED` |

`GET /api/mandates/v1/:id` **refreshes status live from the exchange** before
returning — always poll this endpoint rather than trusting a locally cached
status when you're about to use the mandate for a payment.

### 3.3 Minimum implementation checklist for this step

* [ ] After creating an `ENACH` mandate, surface `auth_link` (or poll for it)
  and drive the investor to approve it with their bank.
* [ ] Track mandate status independently from investor status — don't gate
  UI on investor `ACTIVE` alone.
* [ ] Before submitting a mandate-funded payment, `GET` the mandate fresh (not
  from local cache) to confirm `REGISTERED` and sufficient `amount`.
* [ ] For `PHYSICAL` mandates, build the upload step
  (`POST /api/mandates/v1/:id/image`) and handle its own accept/reject
  (`422 UCC_REJECTED`) outcome.

### 3.4 Public mandate confirmation link (optional self-serve flow)

Same pattern as §2's investor onboarding link and §4.4's order link:
`POST /api/mandates/v1/public-link` (body `{ "proposal": {...MandateRequest}, "secret_code": "1234" }`)
returns `{ link_id }` with nothing registered yet; the investor confirms via
`GET`/`POST /api/public/v1/mandate-links/:link_id` (`/confirm`) — the confirm
response is the normal mandate-create shape, including `auth_link`. `auth_link`
is best-effort and can still be blank right after `confirm` returns (same
§3.1 caveat) — keep polling `GET .../mandate-links/:link_id` every few
seconds until `mandate.auth_link` appears, then redirect; don't assume it's
there immediately. The `GET` response also carries a `bank_display` object
(`{ "account_no": "****1234", "ifsc": "...", "account_type": "..." }`) —
a masked view of the proposal's bank account resolved server-side, since
this endpoint is unauthenticated and must never leak a raw `bank_account_id`
or account number. Same `401`/`410`/`409` semantics as §4.4. See
[Public mandate confirmation link](https://docs.mf-atlas.space/api-documentation/mandates.md#public-mandate-confirmation-link).

***

## 4. Critical nuance #3 — Purchase order creation is two calls: order, then payment (which itself branches by mode)

Placing a lumpsum purchase is **always two separate API calls**, not one:

```
Step 1: POST /api/orders/v1/    → creates + validates + queues the order (202)
Step 2: POST /api/payments/v1/  → references the order(s) and settles funds
```

An order with no payment against it never gets units allotted. Do not treat
step 1's `202` as "purchase complete."

### 4.0 Look up the scheme first

`GET /api/schemes/v1/:code` (or `GET /api/schemes/v1/?search=...` to find the
code) returns whether the scheme currently allows the transaction type you
intend (`purchase_allowed`, `sip_allowed`, ...), plus `min_purchase`,
`purchase_multiple`, `min_additional` and `min_redemption_units` — validate
the order amount client-side against these before submitting, since an
unknown or disallowed `scheme_code` is rejected synchronously with
`400 VALIDATION_FAILED`. Served entirely from ingested master data, so it's
cheap to call on every quote/checkout screen render.

### 4.1 Order creation is itself async

```json theme={null}
POST /api/orders/v1/
{
  "order_type": "LUMPSUM_PURCHASE",
  "investment_account_id": "A_RT_000001",
  "scheme_code": "128SDGP",
  "amount": 25000,
  "purchase_type": "FRESH",
  "bank_account_id": "bnk_9f21c8",
  "mandate_id": "mnd_4a1b2c",
  "client_ref": "ord-5521"
}
```

Returns `202` with `status: "PENDING"` the moment it's validated and
persisted — dispatch to the exchange happens in the background:

```
PENDING → SUBMITTED → ACCEPTED → ALLOTTED
              \-> REJECTED / FAILED         (CANCELLED possible from PENDING/SUBMITTED/ACCEPTED)
```

`ALLOTTED` is set later still, by an asynchronous status-sync job that polls
the exchange and fills in `nav`, `units`, `allotment_date` — this can lag
well behind order acceptance. Poll `GET /api/orders/v1/:id` or listen for
`order.accepted` / `order.rejected` / `order.failed` webhooks; check
`GET /api/orders/v1/:id/events` for the full transition trail if you need to
show history.

`REDEMPTION` and `SWITCH` orders follow the same lifecycle — `ALLOTTED` is
the terminal "processed" state for every order type, and emits the same
`order.allotted` webhook:

* `REDEMPTION` — `ALLOTTED` once the redemption is processed; `units`,
  `nav` and `allotment_date` are the units actually redeemed and the NAV
  they were redeemed at (not the requested figures).
* `SWITCH` — the exchange books a switch as a switch-out plus a switch-in.
  The order becomes `ALLOTTED` only once the switch-in into the target
  scheme is allotted, and `units`/`nav`/`folio_no` then describe the new
  holding in `switch.to_scheme_code`. If either side is rejected, the whole
  order is `REJECTED`.

Prerequisites that must already be true or the order is rejected
synchronously before the `202`:

* `investment_account_id` must exist — the investor is derived from it (not
  a separate field), and must be `ACTIVE` (else `422 KYC_INCOMPLETE`).
* If `mandate_id` given, it's checked for usability immediately
  (`422 MANDATE_NOT_ACTIVE` / `INSUFFICIENT_MANDATE_LIMIT`) — this is a
  preview check, not a substitute for the payment-time check in §3.2.

Always send `Idempotency-Key` on this call — it's the one endpoint in the API
where idempotency is actively enforced, because retrying a failed request
without it risks a duplicate purchase.

### 4.2 Payment settlement branches by `mode` — only one is "fire and forget"

```json theme={null}
POST /api/payments/v1/
{
  "investment_account_id": "A_RT_000001",
  "order_ids": ["ord_1a2b3c4d"],
  "mode": "MANDATE",
  "mandate_id": "mnd_4a1b2c"
}
```

| `mode` | What you must do after `201 INITIATED` |
| - | - |
| `MANDATE` | Nothing — auto-debit is triggered against the registered mandate. Requires the mandate to already be `REGISTERED` (§3). |
| `UPI` / `NETBANKING` | Response includes `payment_url` — **you must redirect/hand this to the investor** to complete payment on a hosted page. Nothing settles until they do. |
| `CHEQUE` | Physical cheque details registered (`cheque: { number, date }`, date within T−90…T+3 days); funds clear out of band — no further API call needed from you, but settlement isn't instant. |
| `NEFT_RTGS` | Investor transfers funds outside the API; **you must then call** `POST /api/payments/v1/:id/utr` with the bank's UTR once available, or settlement never completes. |

Regardless of mode, final settlement is asynchronous and arrives via:

* Webhooks `payment.captured` / `payment.failed` / `payment.refunded`, and
* Each linked order's `payment_status` field (`INITIATED → CAPTURED / FAILED / REFUNDED`)
  updates in step — poll `GET /api/orders/v1/:id` or `GET /api/payments/v1/:id`
  if you're not on webhooks yet.

Only once payment is `CAPTURED` does NAV get applied and units allotted
(order moves to `ALLOTTED`).

### 4.3 Minimum implementation checklist for this step

* [ ] Never treat `POST /api/orders/v1/`'s `202` as final — track `status`
  through to `ALLOTTED`/`REJECTED`/`FAILED` via webhook + poll fallback.
* [ ] Always send a unique `Idempotency-Key` per logical order submission;
  reuse the same key only when deliberately retrying the identical
  request.
* [ ] Branch your post-payment UI/flow by `mode`:
  * `MANDATE`/`CHEQUE` → just wait for the webhook/poll.
  * `UPI`/`NETBANKING` → redirect the investor to `payment_url` and only then
    wait.
  * `NEFT_RTGS` → collect the UTR from the investor and call
    `POST /api/payments/v1/:id/utr` yourself, or the payment never settles.
* [ ] Subscribe to `payment.captured`/`payment.failed`/`payment.refunded` and
  `order.accepted`/`order.rejected`/`order.failed`.
* [ ] Before defaulting to `mode: MANDATE`, re-fetch the mandate (§3.2) to
  confirm it's actually `REGISTERED` and its `amount` covers this order —
  don't rely on a status you cached earlier in the session.

### 4.4 Public order confirmation link (optional self-serve flow)

Instead of calling §4.1/§4.2 yourself, you can let the investor place and pay
for the order themselves — the same pattern as the investor onboarding link
(§2's `POST /api/investors/v1/:id/public-link`), generalized to orders:

```
Step 1: POST /api/orders/v1/public-link                        → { link_id }
Step 2: GET  /api/public/v1/order-links/:link_id                → proposal, then live status
Step 3: POST /api/public/v1/order-links/:link_id/confirm        → creates + dispatches (same as §4.1)
Step 4: (poll step 2 until payment_ready: true)
Step 5: POST /api/public/v1/order-links/:link_id/pay            → same mode-branching as §4.2
```

Step 1 takes the same body as `POST /api/orders/v1/` wrapped as
`{ "proposal": {...}, "secret_code": "1234" }` — no order exists until step 3.
Steps 2/3/5 are unauthenticated (no bearer token) and instead gated by the
opaque `link_id` plus, if you set one, the `secret_code` PIN sent back as an
`X-Draft-Secret` header — `401` if it's wrong or missing, `410 GONE` once the
link expires (7 days after creation) or `409 ALREADY_CONFIRMED` on a second
confirm. Step 2's response also carries `scheme` (the proposal's
`scheme_code` resolved to `{ scheme_code, name, amc_name, plan, option,
category }`) and, for a `SWITCH`, `to_scheme` (the switch target) — both
best-effort, omitted if the code isn't in the scheme master — so the
investor sees which fund they're leaving and which they're entering. For a
`REDEMPTION`/`SWITCH` nothing is paid; the page should say so. See [Public order confirmation link](https://docs.mf-atlas.space/api-documentation/purchase-orders.md#public-order-confirmation-link)
for the full request/response shapes.

***

## 4a. SIP — register once, installments are automatic

A SIP is **one** `POST /api/orders/v1/` with `order_type: "SIP"` and a `sip`
block (`frequency`, `start_date`, `installments`, optional `step_up`);
`amount` is the per-installment amount and `mandate_id` funds the debits.
Differences from a lumpsum purchase that an implementation must respect:

* **The start date is the schedule.** `sip.start_date` must be **at least 7
  days from today** (Indian date) and drives every installment: `DAILY` runs
  every business day, `WEEKLY` on the start date's weekday, and `MONTHLY` /
  `QUARTERLY` / `HALF_YEARLY` / `YEARLY` on the start date's day of month. For
  those, the start date must fall on **day 1–28** and on one of the scheme's
  `sip_dates` (read them from the scheme). There is no `day` field. Offer
  the investor a date picker, not a free "day" input.

* **No payment call.** Never call `POST /api/payments/v1/` for a SIP. Once the
  order is `ACCEPTED`, `GET /api/orders/v1/:id` carries a `payment_link` — an
  *authorisation* page for the investor to approve the plan. No money moves
  there. It may take a moment to appear; poll or use `order.accepted`.

* **First installment today (`sip.first_order_today: true`).** That first
  installment is paid as a one-time lumpsum, not debited from the mandate. No
  extra call is needed: after the investor approves the SIP on the
  authorisation link, the exchange redirects them to its payment page
  automatically. Don't call `POST /api/payments/v1/` for it; just make sure the
  investor completes that page. Later installments are mandate-debited as usual.

* **Lifecycle:** `PENDING → SUBMITTED → ACCEPTED → REGISTERED` (live), with
  `PAUSED`, `CANCELLED`, `REJECTED`, `FAILED` as other states. A SIP is never
  `ALLOTTED`; each installment is allotted individually. `order.registered`
  fires when it goes live and `order.cancelled` when it is cancelled (by you,
  the investor or the fund house).

* **Installments are not created by you.** They are debited automatically on
  due dates. `GET /api/orders/v1/:id/installments` is a read-only history of
  installments the exchange has raised (`status`: `PENDING` → `PAID` →
  `ALLOTTED`, or `REJECTED` — only ever advanced on the exchange's
  confirmation); `systematic.next_due_date`,
  `installments_paid`, `total_paid` and `last_paid_date` on the order show
  progress. These refresh periodically and may trail a debit by hours.

* **Validate against the scheme first:** `sip_allowed`, `sip_frequencies`,
  `sip_dates`, `min_sip` (§4.0). Violations are `400 VALIDATION_FAILED`.

* **Manage:** `POST /:id/pause` `{ from, no_of_installments }` (only while
  `REGISTERED`/`PAUSED`; pauses the next two installments from `from`, and
  calling it on a `PAUSED` SIP resumes it; not for `DAILY`), `PATCH /:id`
  (amount and contact fields only), `POST /:id/cancel`. The status changes in
  the pause call itself. Full reference: `api-documentation/sip-orders`.

## 5. Webhooks vs polling — recommended pattern

All three flows above (investor, mandate, order+payment) share the same
shape: an initial synchronous acknowledgement, then an asynchronous outcome.
Build one reusable mechanism rather than three.

### 5.1 Subscribe

```json theme={null}
POST /api/webhooks/v1/subscriptions
{ "url": "https://yourapp.example.com/hooks/mf-atlas",
  "events": ["investor.*", "order.*", "payment.*", "mandate.*"] }
```

Returns `{ id, url, events, secret }` — `secret` is shown **once**, at
creation; store it, it's what you use to verify `X-MF-Signature` on every
delivery and cannot be retrieved later. `url` must be `https://` and resolve
to a public address (no `localhost`/private/loopback ranges — enforced at
subscribe time and again on every delivery).

### 5.2 Event catalog

| Event | `data` shape |
| - | - |
| `investor.registered` / `investor.activated` / `investor.rejected` | `{ investor_id, status, provider_remark }` — `registered` carries `REGISTERED` (client authorisation pending), `activated` carries `ACTIVE` |
| `mandate.registered` / `mandate.rejected` / `mandate.cancelled` | `{ mandate_id, status }` |
| `order.accepted` / `order.registered` / `order.cancelled` / `order.rejected` / `order.failed` / `order.allotted` | `{ order_id, status, provider_remark }` |
| `payment.captured` / `payment.failed` / `payment.refunded` | `{ payment_id, status }` |
| `kyc.status_changed` | `{ investor_id, kyc_status }` |
| `broker_change.submitted` / `broker_change.rejected` (**beta**) | `{ broker_change_id, status, provider_remark }` |

Every payload is the usual `{ success, data }` envelope. Treat it as a
trigger to `GET /api/.../:id` for the authoritative record — it is
intentionally thin, not the full resource.

### 5.3 Delivery, signing, retries

Each delivery is a `POST` to your `url` with headers `X-MF-Event` (event
name), `X-MF-Delivery` (unique per attempt — use it to de-duplicate),
`X-MF-Signature` (`t=<unix>,v1=<hex HMAC-SHA256("<t>.<raw body>", secret)>`).
Respond `2xx` within 10s or it's treated as a failure.

Failed deliveries retry with backoff — **1m, 5m, 25m, 2h, 12h** — then are
marked `PARKED` (no further automatic retries; replay manually with
`POST /api/webhooks/v1/deliveries/:id/retry`). Delivery is **at-least-once**
and can arrive out of order — de-duplicate on `X-MF-Delivery`, and always
trust the resource's own `status` field over delivery order.

```
Node.js verification:
const [tPart, vPart] = header.split(",");
const t = tPart.split("=")[1], sig = vPart.split("=")[1];
const expected = crypto.createHmac("sha256", secret)
  .update(`${t}.${rawBody}`).digest("hex");
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); // must use the RAW body
```

### 5.4 Build pattern

1. Register one webhook endpoint that dispatches by `X-MF-Event`.
2. On each event, update the local record's status and trigger whatever
   downstream action depends on it (e.g. unlock "place order" once investor
   is `ACTIVE`; unlock "mark account funded" once payment is
   `CAPTURED`).
3. Run a low-frequency reconciliation poll (e.g. every 15–30 min) over any
   record still in a non-terminal status past a reasonable SLA, in case a
   webhook delivery was ever `PARKED` or lost. For investors, prefer
   `POST /api/investors/v1/sync` for bulk reconciliation over N individual
   `GET`s; for other resources, list `GET /api/webhooks/v1/deliveries?status=PARKED`
   and replay.
4. Never build a synchronous "wait and block" loop against these endpoints —
   design your UI to show a pending/processing state and update it when the
   webhook/poll resolves.

Full reference: [Webhooks](https://docs.mf-atlas.space/api-documentation/webhooks.md) (see "Documentation & spec links" at the top of this file).

***

## 5a. Testing this flow without a live exchange — sandbox simulation

In a sandbox environment (anywhere other than production), every async wait
described in sections 2–4 above — the investor confirmation link (§2.3), the
mandate verification link (§3.1), order dispatch/allotment (§4.1), and
payment settlement (§4.2) — can be driven manually instead of waiting on NSE,
a bank, or a payment gateway. Each create/get response for that resource
carries a `simulation_links` object (absent in production) with public,
unauthenticated `accept`/`reject` GET links you call directly (browser or
`curl`) to move the resource to its next status, firing the exact same
webhook event a real response would. Build your UI/integration against
`simulation_links` in a sandbox account to exercise every branch (registered
vs. rejected, allotted vs. rejected, captured vs. failed, mandate approved
vs. declined) without needing live exchange credentials.

Full reference: [Sandbox Simulation](https://docs.mf-atlas.space/api-documentation/simulation.md).

***

## 6. Error handling

Branch only on `error.code` (see the table below); `error.message` and
`provider_remark` are human strings for logs/support tickets, not for control
flow.

| `code` | HTTP | Meaning |
| - | - | - |
| `VALIDATION_FAILED` | 400 | Request shape or business rule failed locally. |
| `UNAUTHORIZED` | 401 | Missing / invalid / expired token. |
| `FORBIDDEN` | 403 | Valid token, missing scope. |
| `NOT_PROVISIONED` | 403 | No active account for these credentials. |
| `NOT_FOUND` | 404 | Not found within your account. |
| `IDEMPOTENCY_KEY_REUSED` | 409 | Same key, different body. |
| `INVALID_STATE` | 409 | Illegal state transition (e.g. cancelling an allotted order). |
| `INVALID_SCHEME` | 422 | Scheme doesn't allow this transaction. |
| `KYC_INCOMPLETE` | 422 | Investor is not `ACTIVE` yet (still onboarding, or `REGISTERED` with client authorisation pending). |
| `MANDATE_NOT_ACTIVE` | 422 | Mandate isn't usable. |
| `INSUFFICIENT_MANDATE_LIMIT` | 422 | Debit exceeds the mandate ceiling. |
| `UCC_REJECTED` | 422 | The exchange rejected an investor/mandate registration. |
| `ORDER_REJECTED` | 422 | The exchange rejected the order. |
| `PAYMENT_REJECTED` | 422 | The exchange rejected the payment. |
| `RATE_LIMITED` | 429 | Back off; `Retry-After` is set. |
| `PROVIDER_UNAVAILABLE` | 502 | Exchange unreachable or erroring; safe to retry. |
| `INTERNAL` | 500 | Unexpected server error. |

Retry policy: `PROVIDER_UNAVAILABLE` and `RATE_LIMITED` (respecting
`Retry-After`) are safe to retry with backoff. Anything else (`VALIDATION_FAILED`,
`*_REJECTED`, `KYC_INCOMPLETE`, etc.) requires fixing the input or the
upstream state (e.g. waiting for investor registration) before retrying —
don't blind-retry these.

***

## 6a. Change of broker (BETA) — move a folio to another distributor

> **Beta.** Built on the exchange's thinly-specified NFT API; not yet verified
> end to end against the live exchange. Shapes and statuses may change.

A non-financial request: `POST /api/broker-changes/v1/` (send an
`Idempotency-Key`) with `investor_id`, `amc_code`, `folio_no`, `auth_mode` and
either `broker_code` (ARN) or `ria_code`.

* `auth_mode: TWO_FACTOR` (+ `two_fa_channel` MOBILE/EMAIL/BOTH) → status
  `SUBMITTED` immediately; the exchange handles the 2FA with the investor.
* `auth_mode: WET_SIGNATURE` (+ `email`) → status `PENDING_DOCUMENT`; then
  `POST /api/broker-changes/v1/:id/image` (multipart `file`, ≤10 MB) with the
  signed form → `SUBMITTED`. Do not skip this: the exchange will not act on a
  wet-signature request without the form.
* `REJECTED` is terminal (read `provider_remark`, then file a new request).
* There is **no final-outcome status**: `SUBMITTED` is the last state the API
  reports. Don't build UI that waits for a "completed" state.

Implementation checklist: persist `id` + `status`; handle both webhooks; show
a "document required" state for `PENDING_DOCUMENT`; treat `SUBMITTED` as
"filed", not "done".

***

## 6b. Holdings — what a customer currently owns

> **Prerequisite — email forwarding.** Holdings come from the report emails CAMS and
> KFintech send to the account's registered mailback address. That mailbox must
> **automatically forward every CAMS and KFintech report email to
> `reports@mailbacks.mf-atlas.space`**; without the rule holdings stay empty (see §6c).

Two read-only endpoints return folio-level positions as reported by the
registrars (CAMS and KFintech), not derived from your own orders:

* `GET /api/investors/v1/{investor_id}/holdings` — for a registered investor.
* `POST /api/holdings/v1/lookup` with `{"pan": "ABCDE1234F"}` — by PAN, for
  **any** customer under your distributor code, including ones not onboarded on
  the platform. The PAN goes in the body (never the URL); the response echoes it
  masked. Malformed PAN → `400 VALIDATION_FAILED`.

Behaviour to design for:

* Data is refreshed **once a day** from the registrars' reports; every position
  carries `as_of` (YYYY-MM-DD, the balance date). Show it to the user — never
  present these as live values.
* `holdings` is **empty until a registrar report covering the PAN has been
  processed**. Empty is not an error (`200`, `holdings: []`).
* Only folios reported under the holder's **primary PAN** appear: a joint folio
  shows under the primary holder only, and joint-holder investors always return
  an empty list from the by-investor endpoint.
* A position may carry a `contact` object — the **masked** email and phone
  numbers (`email`, `mobile` (KFIN only), `phone_residence`, `phone_office`) the
  registrar holds for the folio. It comes from a different registrar report than
  the position, so it can be **absent for a while**: treat a missing `contact` as
  "not known yet", not "no email or phone".
* Each position carries `amc_name`, `scheme_code` (NSE), `isin`, `rta_code` and
  `fund_name`, matched to our masters (schemes of your account's plan only — regular by default —, by ISIN then the
  registrar's product code). A registrar code usually matches **several** listings:
  order-tier duplicates (`-L0`/`-L1`) of one scheme, for which the **base scheme
  without a tier suffix** (lowest minimum purchase among equals) is used as
  `scheme_code`; or different variants of the fund (growth / IDCW payout / IDCW
  reinvest, IDCW frequencies), for which `scheme_code` is **left empty** rather than
  guessing an option. `fund_name` identifies the fund, not necessarily the exact
  plan/option; `isin` / `rta_code` are filled only when all matches agree. **Never assume `scheme_code` or `isin`
  is present**; an empty `scheme_code` is normal, not an error, and when unmatched
  `isin`/`fund_name` (KFIN only) and `rta_code` are the registrar's own values.
  `amc_name` may be empty if the AMC couldn't be mapped. For CAMS positions `nav`
  is derived (`market_value / units`). There is no cost/invested amount.
* Unknown investor id → `404 NOT_FOUND`.

## 6c. Mailback automation — request the CAMS reports that feed holdings

**Mail forwarding is the other half and is required.** Automation only *subscribes to /
requests* CAMS reports; CAMS (and KFintech, whose reports you still request at
KFintech) then email a link to the registered mailback address. The mailbox must
auto-forward those emails to **`reports@mailbacks.mf-atlas.space`**, from the address
configured as the account's mailback email (emails are matched to the account by it;
forward rather than redirect so the original recipient survives in the headers; some
providers need the forwarding address verified — ask your Atlas administrator for the
verification message). Set the rule up once, then verify with a `WBR4` request (`IMMEDIATE`; `WBR53`
is overnight-only). Only `WBR53`/`WBR4` (CAMS) and `MFSD203` (KFin) are ingested;
other CAMS reports just land in the mailbox.

Holdings are built from the reports CAMS emails to the account's mailback address.
Mailback automation asks CAMS for them. It is **opt-in per account** (an administrator
enables it; until then every call below is `403 FORBIDDEN`) and needs the account's
mailback email and CAMS report password to be configured (`409 INVALID_STATE` otherwise).
Only `source = cams` exists today.

* `POST /api/mailback-automation/v1/sources/cams/requests` with `{ "report": "WBR53",
  "timing": "IMMEDIATE" | "OVERNIGHT" }` → `202`, `status: QUEUED`. **`QUEUED` only means
  accepted** — CAMS then emails the report (minutes for `IMMEDIATE`, later for `OVERNIGHT`),
  it is ingested automatically and shows up in holdings. There is no API that returns the
  report file; `FAILED` means it could not be queued — request again.
* `GET` / `PUT /api/mailback-automation/v1/sources/cams/config` — the account's **one**
  schedule: `{ enabled, reports[], timing, run_at "HH:MM" IST, days[] (MON..SUN, empty =
  daily) }`. `available_reports` lists the CAMS report codes you may use (most `WBR…` reports; only `WBR53` and `WBR4` are ingested into holdings, the rest are just emailed; `WBR3`, `WBR12`, `WBR27`, `WBR35`, `WBR53` and `WBR54` are `OVERNIGHT` only). It runs at
  most once a day, within \~15 minutes after `run_at`; `last_enqueued_on` shows the last day.
* `GET /api/mailback-automation/v1/sources/cams/requests` — history (on-demand and
  scheduled), newest first, `next_cursor` paged.

Do not poll for completion; read holdings (§6b) after the report email would have arrived.

## 7. Suggested build order for the implementing agent

0. Fetch `https://docs.mf-atlas.space/openapi.yaml` (see "Documentation & spec
   links" above) and generate request/response types or a client stub from it
   before writing any hand-rolled structs — it's kept in sync with the live
   API on every deploy, this document is not.
1. Token exchange + auth wrapper (attach bearer, refresh before expiry).
2. Investor create/read/patch/retry + webhook handlers + reconciliation poll
   (§2). Build and test the `REJECTED → fix → resubmit` loop explicitly.
3. Investment account create/read (simple, synchronous, low risk).
4. Mandate create/read + `auth_link` surfacing + physical-mandate image
   upload (§3).
5. Order create + payment create, with the mode-branching logic in §4.2 and
   full webhook coverage for both resources.
6. Idempotency-key generation/storage on every order submission.
7. Central error-code → retry/no-retry policy (§6), reused everywhere.
8. Optional, beta: change of broker (§6a).
9. Optional: holdings read (§6b) — a daily-refreshed portfolio view, by investor or by PAN.

Do not consider the integration "done" after step 5 compiles and returns
`201`/`202` — every flow above ends with an asynchronous, often
investor-facing, confirmation step. The integration is only correct once
those confirmations are wired to real state transitions in your own system.


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