# Gamma Duo Partner Application API

Submit loan applications collected on your own website, form or system into the
Gamma Duo platform. Each submission is authenticated, validated, mapped to our
lender's schema and forwarded in real time.

- **Protocol:** HTTPS, JSON, `POST`
- **Auth:** per-partner API key in the `X-API-Key` header
- **Integration style:** server-to-server (do **not** call this from a browser:
  your key must never reach a client device)
- **Version:** `v1` (breaking changes ship under a new path, e.g. `/api/v2/…`)

> Prefer to let applicants fill in **our form on your website** instead of
> building your own? See the [Form & Embed guide](/docs/form): a drop-in
> `<script>` widget that submits through the same pipeline.

---

## 1. Environments & base URLs

| Environment | Base URL |
| --- | --- |
| UAT / testing | `https://<uat-deployment-host>` |
| Production | `https://<prod-deployment-host>` |

We provide your exact host(s) at onboarding. All examples below use
`$BASE_URL`. The current upstream lender environment is Nimo **UAT**.

Health probe (no auth): `GET $BASE_URL/api/v1/health` →
```json
{
  "ok": true,
  "service": "gamma-duo-intake",
  "time": "2026-07-13T05:00:00.000Z",
  "nimo": "configured",
  "db": "configured",
  "submitToNimo": true,
  "partners": 1
}
```
The response carries additional operational fields; the two that matter for
integrators are `nimo` (`"simulated"` means lender credentials aren't
configured yet; see §6) and `partners` (how many partner keys are active).
`?deep=1` additionally verifies database connectivity.

---

## 2. Authentication

Send your key on every request:

```
X-API-Key: pk_live_xxxxxxxxxxxxxxxxxxxxxxxx
```

- Keys are issued per partner and identify your organisation; every application
  you submit is attributed to your `partnerId`.
- Applications created through the partner API are recorded as
  **broker-originated** (channel `broker`) in our system - they appear that way
  in our ops console and reporting, alongside your `partnerId`.
- Every application you send is also labelled **"Referred by"** with your
  registered organisation name, taken from your key. It is derived from the
  authenticated key on our side, not from the payload, so there is nothing to
  send for it - and no caller can submit under another partner's name. Tell us
  if the name we show should change.
- API keys are for **server-to-server integrations**. Human brokers who want to
  sign in, start applications and track their pipeline use the **broker
  portal** instead (invited by the Gamma Duo team) - ask us if that fits your
  workflow better. The two can coexist for one brokerage.
- Store the key as a server-side secret (env var / secret manager). Never commit
  it or expose it in front-end code.
- To rotate or revoke a key, contact us; we can run old and new keys in parallel
  during a cutover.

Missing/invalid keys are rejected before any processing (see §5).

---

## 3. Endpoint

```
POST $BASE_URL/api/v1/partner-applications
Content-Type: application/json
X-API-Key: <your key>
```

Request envelope:

```jsonc
{
  "externalApplicationId": "your-ref-99881",  // optional: your own reference, echoed back
  "application": { /* canonical application, see §4 */ }
}
```

### 3.1 Hand-off drafts (server-side prefill)

If you *don't* hold a complete application - you captured some details and want
the applicant to finish the rest themselves - create a **hand-off draft**
instead of a submission:

```
POST $BASE_URL/api/v1/partner-drafts
Content-Type: application/json
X-API-Key: <your key>
```

Same envelope as §3, but **every `application` field is optional** (send what
you have; unknown fields are ignored). The only requirement is someone to hand
off to: at least one of `applicant.firstName`, `applicant.email` or
`applicant.phone`, else `422 nothing_to_prefill`.

```json
{
  "externalApplicationId": "your-ref-99881",
  "application": {
    "product": "car",
    "applicant": { "firstName": "Ana", "lastName": "Reyes", "email": "ana@example.com", "phone": "0400 999 888" },
    "loan": { "amount": 18000, "termYears": 5 }
  }
}
```

200 response:

```json
{
  "applicationId": "c3d4e5f6-a7b8-4c9d-8e0f-1a2b3c4d5e6f",
  "resumeToken": "rt_9kq…",
  "resumeUrl": "https://apply.gammaduo.com.au/?resume=c3d4e5f6-…&rt=rt_9kq…",
  "status": "lead",
  "partnerId": "broker_123",
  "externalApplicationId": "your-ref-99881"
}
```

Send the applicant to **`resumeUrl`** (email/SMS it, or redirect them): the
form opens with everything you supplied already filled in, and they complete
the remaining steps and submit. It's one application file end to end - your
`partnerId`/`externalApplicationId` stay attached through final submission,
and our operations team sees the draft immediately.

Notes:

- **The resume link is a credential.** Anyone with the URL can open (and
  continue) that draft - deliver it to the applicant privately, don't post it
  anywhere public. We store only a hash of the token.
- `resumeUrl` is `null` if the hosted form's base URL isn't configured for the
  environment; the recipe is
  `{form base}/?resume={applicationId}&rt={resumeToken}` (add
  `&product=personal` for personal loans).
- **Consents and declarations can never be prefilled** - `consents`,
  repayment-issue declarations and bank-statement fields in the payload are
  ignored. The applicant answers those in the form.
- **Idempotent on `externalApplicationId`.** Re-posting the same
  `externalApplicationId` returns the **same application** (the response
  carries `"existing": true`) instead of creating another one - so retries
  and double-fires are harmless. Each repeat rotates the resume link: the
  newest `resumeUrl` is the live one and earlier links stop working, so
  always deliver the link from your **latest** response. While the applicant
  hasn't opened the form yet, a repeat also **refreshes the prefill** with
  whatever you send; once they've started, their answers are kept and only
  the link rotates. If the application has already been submitted, a repeat
  returns `409 application_already_submitted` (with the `applicationId`).
- Omitting `externalApplicationId` disables this: every call then creates a
  separate draft. Always send your reference.
- Drafts follow the platform's data-retention window: an abandoned draft is
  eventually anonymised, after which the link stops working.
- Prefer this over the client-side `?prefill=` links in the embed guide when
  you have an API key: nothing travels in the applicant's URL, and the lead
  exists on our side even if they never open the link.

---

## 4. The `application` object (canonical schema)

Field names below are **exact**. Unknown fields are ignored. Send what you have;
only the fields marked **required** must be present.

> Note: fields that exist only in our broker portal's version of the form
> (e.g. the broker's own reference and authority declaration) are not part of
> this schema - use `externalApplicationId` for your reference.

### 4.1 Top level
| Field | Type | Notes |
| --- | --- | --- |
| `product` | string | **required**: `"car"` or `"personal"`. (`"commercial"` asset finance exists on our platform but has **no lender integration yet** - sending it captures the application for manual processing instead of lodging it; treat it as unsupported here until announced.) |
| `applicant` | object | **required**, see §4.2 |
| `loan` | object | **required**, see §4.3 |
| `consents` | object | **required**, see §4.6 |
| `vehicle` | object | car loans, see §4.4 |
| `employment` | object | see §4.5 |
| `expenses` | object | monthly amounts (AUD), see §4.7 |
| `assets` | array | see §4.8 |
| `liabilities` | array | see §4.8 |

### 4.2 `applicant`
| Field | Type | Req | Notes |
| --- | --- | --- | --- |
| `firstName` | string | ✅ | min 2 chars |
| `lastName` | string | ✅ | min 2 chars |
| `dateOfBirth` | string | ✅ | `YYYY-MM-DD`, applicant must be 18 to 120 |
| `email` | string | ✅ | valid email |
| `phone` | string | ✅ | AU mobile recommended, e.g. `0411223344` (≥ 8 digits) |
| `address` | string | ✅ | single-line residential address (see note) |
| `previousAddress` | string | | supply if < 3 years at current address |
| `postalSameAsResidential` | boolean | | default `true` |
| `postalAddress` | string | | required when `postalSameAsResidential` is `false` |
| `maritalStatus` | string | | `SINGLE` \| `MARRIED` \| `DEFACTO` \| `DIVORCED` \| `WIDOWED` |
| `residentialStatus` | string | | `OWN_MORTGAGE` \| `OWN_NO_MORTGAGE` \| `RENTING` \| `BOARDING` \| `WITH_PARENTS` \| `CARAVAN` \| `OTHER` |
| `residencyStatus` | string | | `AU_CITIZEN` \| `AU_PERMANENT_RESIDENT` \| `AU_TAX_RESIDENT` \| `NON_AU_RESIDENT` |
| `dependents` | integer | | number of dependants |
| `yearsAtAddress` | integer | | years at current address |
| `isExistingCustomer` | boolean | | default `false` |

> **Address note:** send a single line as `"<unit>/<number> <street>, <suburb> <STATE> <postcode>"`
> (e.g. `"8/210 Coronation Drive, Milton QLD 4064"`). We parse it into structured
> components best-effort; the full line is always preserved.

### 4.3 `loan`
| Field | Type | Req | Notes |
| --- | --- | --- | --- |
| `amount` | number | ✅ | > 0. Net amount to finance. Personal loans: the net amount (after any `deposit`) must be $8,000-$60,000, the lender product's pricing range - outside it the submission is rejected with a validation error |
| `purpose` | string | ✅ for `personal` | `DEBT_CONSOLIDATION` \| `HOME_IMPROVEMENT` \| `TRAVEL` \| `WEDDING` \| `MEDICAL` \| `MAJOR_PURCHASE` \| `EDUCATION` \| `OTHER` |
| `purposeDetail` | string | | What the funds will be used for, in the applicant's words (up to 300 chars). Strongly recommended when `purpose` is `OTHER` or `MAJOR_PURCHASE` - the assessment team needs the true purpose. The hosted form requires it for both; the partner API does not, so existing integrations keep working |
| `deposit` | number | | car loans: deposit / trade-in |
| `termYears` | number | | preferred term; feeds the lender's product selection (defaults to 5 when omitted) |
| `repaymentFrequency` | string | | `weekly` \| `fortnightly` \| `monthly`; feeds the lender's product selection (defaults to `monthly`) |

### 4.4 `vehicle` (car loans)
| Field | Type | Notes |
| --- | --- | --- |
| `found` | boolean | `true` if a specific vehicle is identified |
| `rego` | string | registration plate |
| `state` | string | `NSW` \| `VIC` \| `QLD` \| `SA` \| `WA` \| `TAS` \| `ACT` \| `NT` |
| `make`, `model` | string | |
| `year` | string | year of manufacture, e.g. `"2022"` |
| `vin` | string | |
| `bodyType` | string | free text, e.g. `Sedan`, `SUV`, `Ute` |
| `value` | number | estimated / purchase value |
| `isNew` | boolean | `true` new, `false` used |

### 4.5 `employment`
| Field | Type | Notes |
| --- | --- | --- |
| `type` | string | `PAYG` \| `CASUAL` \| `CONTRACT` \| `SELF_EMPLOYED` \| `RETIRED` \| `UNEMPLOYED` |
| `time` | string | `FULL_TIME` \| `PART_TIME` \| `CASUAL`. The lender requires a basis whenever `type` is `PAYG`/`SELF_EMPLOYED` - if omitted we submit `FULL_TIME` |
| `employer` | string | |
| `employerContactNumber` | string | **required** unless `type` is `RETIRED`/`UNEMPLOYED` (min 8 digits). The lender uses it to verify employment |
| `occupation` | string | |
| `startDate` | string | `YYYY-MM` or `YYYY-MM-DD` |
| `onProbation` | boolean | defaults to `false` when omitted |
| `grossIncome` | number | |
| `incomePeriod` | string | `WEEKLY` \| `FORTNIGHTLY` \| `MONTHLY` \| `ANNUALLY` |
| `averageAnnualIncome` | number | **required when `type` is `SELF_EMPLOYED`** - taxable income from the business averaged over the last two years. The lender assesses a business on this rather than a single year |
| `businessAddress` | string | where the business trades from, single line. Defaults to the applicant's residential address |
| `businessAddressSameAsResidential` | boolean | default `true` |
| `previousEmployer` | string | supply when the business has traded for under 3 years - the lender asks what came before it |
| `previousEmploymentStartDate` | string | `YYYY-MM` or `YYYY-MM-DD` |

> **Self-employed applicants** need the `business` object as well (§4.9). The
> lender treats a self-employed income record as a *business* record and
> rejects the submission without the ABN, the entity type, the trading-since
> date and the two-year average.

### 4.9 `business` (self-employed applicants, and asset finance)

Required whenever `employment.type` is `SELF_EMPLOYED`. Asset finance uses the
same object for the entity being financed, so one shape serves both.

| Field | Type | Req | Notes |
| --- | --- | --- | --- |
| `abn` | string | ✅ | 11 digits. Spaces are fine - we strip them |
| `entityName` | string | ✅ | the business name (max 50 chars reaches the lender) |
| `entityType` | string | ✅ | `SOLE_TRADER` \| `COMPANY` \| `PARTNERSHIP` \| `TRUST` |
| `abnActiveSince` | string | ✅ | `YYYY-MM-DD` - when the ABN became active. Under 3 years ago, also send `employment.previousEmployer` |
| `abnVerified` | boolean | | `true` if you checked it against the Australian Business Register |
| `abnStatus` | string | | what the register said, e.g. `Active` |
| `abnAgeMonths` | integer | | months since `abnActiveSince` |
| `gstRegistered` | boolean | | |

Our own form fills all of this from one ABN look-up; if you already hold the
ABN, sending it plus the name, type and start date is enough.

### 4.6 `consents`
| Field | Type | Req | Notes |
| --- | --- | --- | --- |
| `creditCheck` | boolean | ✅ | must be `true` |
| `privacyPolicy` | boolean | ✅ | must be `true` - the [Privacy Policy & Consent](/privacy) |
| `dataExchange` | boolean | ✅ | must be `true` - the [Consent to the Exchange of Data](/consent-to-exchange-data) |
| `creditGuide` | boolean | ✅ | must be `true` - applicant confirms they have read the [Credit Guide](/credit-guide) |
| `marketing` | boolean | | optional: marketing communications consent |
| `contactOptIn` | boolean | | optional: service messaging about this application (progress / resume emails) |

> You must show the applicant the disclosure documents and obtain their consent
> **before** submitting: the Privacy Policy & Consent, the Consent to the
> Exchange of Data and the Credit Guide are all published at the links above on
> the application host. Submissions without all four required consents set to
> `true` are rejected (422). Hand-off drafts (§3.1) are different: consents
> can never be preset there - the applicant ticks them in the form.

### 4.7 `expenses` (monthly AUD)
Object with any of these numeric keys (note the `subscriptionTv` casing):
`rent`, `utilities`, `telecommunications`, `insurance`, `groceries`, `diningOut`,
`vehiclesTransport`, `subscriptionTv`, `entertainment`, `educationChildcare`.

### 4.8 `assets` / `liabilities`
```jsonc
"assets": [
  { "type": "SAVINGS", "value": 15000 },                 // PROPERTY|VEHICLE|SHARES|SUPERANNUATION|SAVINGS|OTHER
  { "type": "PROPERTY", "value": 700000, "purpose": "LIVE_IN", "propertyType": "HOUSE" },
  { "type": "OTHER", "description": "Boat", "value": 9000 }
],
"liabilities": [
  { "type": "CREDIT_CARD", "lender": "ANZ", "balance": 2000, "monthlyRepayment": 100, "limit": 15000 }
  // type: HOME_LOAN|CAR_LOAN|PERSONAL_LOAN|CREDIT_CARD|BNPL|SALARY_ADVANCE|OTHER
]
```

Property assets: the lender requires `purpose` (`LIVE_IN` | `INVESTMENT`) and
`propertyType` (`HOUSE` | `APARTMENT_UNIT` | `TOWNHOUSE_VILLA` |
`RESIDENTIAL_VACANT_LAND` | `COMMERCIAL_PROPERTY`) on every `PROPERTY` asset.
Send them when you know them; if omitted we derive them (a homeowner's first
property is treated as `LIVE_IN`, anything else as `INVESTMENT`, type
`HOUSE`). Liabilities: the lender requires a credit `limit` on every
liability; if omitted we submit the `balance` as the limit.

---

## 5. Examples

Copy-paste, tested payloads live in [`docs/examples/`](./examples) and are
verified by our test suite on every change:

- [`partner-application.minimal.json`](./examples/partner-application.minimal.json): required fields only
- [`partner-application.full.json`](./examples/partner-application.full.json): a complete application
- [`partner-application.self-employed.json`](./examples/partner-application.self-employed.json): a self-employed applicant, with the `business` object the lender requires

Minimal request:

```json
{
  "externalApplicationId": "acme-form-10231",
  "application": {
    "product": "personal",
    "applicant": {
      "firstName": "Sam", "lastName": "Lee", "dateOfBirth": "1988-07-01",
      "email": "sam.lee@example.com", "phone": "0400111222",
      "address": "44 Smith Street, Collingwood VIC 3066"
    },
    "loan": { "amount": 15000, "purpose": "DEBT_CONSOLIDATION" },
    "consents": { "creditCheck": true, "privacyPolicy": true }
  }
}
```

cURL:

```bash
curl -sS -X POST "$BASE_URL/api/v1/partner-applications" \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $PARTNER_API_KEY" \
  --data @docs/examples/partner-application.full.json
```

---

## 6. Responses

### 200: accepted & submitted
```json
{
  "applicationId": "3f9d2c80-5b7e-4c1a-9f0a-2d6b8e4a1c55",
  "reference": "GGD-411394",
  "status": "submitted",
  "externalApplicationId": "acme-form-99881",
  "partnerId": "broker_123",
  "nimo": { "message": "Application Created Successfully" }
}
```
- `reference`: quote this to the applicant.
- `applicationId`: a UUID; store it against your record for support/correlation.
- The application is also recorded on the Gamma Duo platform: our operations
  team sees it (with your `partnerId` and `externalApplicationId`) and can
  assist the applicant from there.
- In UAT before credentials are live, success responses also carry
  `"simulated": true` and nothing is sent to the lender. Treat a `simulated`
  response as *accepted for testing*, not a real lodgement.

### Error responses
All errors are JSON with an `error` code. Full catalogue:

| HTTP | `error` | When | Retry? |
| --- | --- | --- | --- |
| 400 | `invalid_json` | body isn't valid JSON | No, fix the request |
| 400 | `missing_application` | no `application` object. If the canonical fields are at the **top level** instead, the `message` says so and `foundAtTopLevel[]` lists them - see below | No, fix the request |
| 401 | `unauthorized` | missing or invalid `X-API-Key` (`message` says which) | No, fix the key |
| 503 | `not_configured` | partner programme not enabled on this host | No, contact us |
| 405 | `method_not_allowed` | not a `POST` | No |
| 422 | `validation_failed` | see `fields[]` | No, fix and resubmit |
| 422 | `nothing_to_prefill` | hand-off drafts (§3.1): no `applicant.firstName`/`email`/`phone` supplied | No, fix the request |
| 409 | `application_already_submitted` | hand-off drafts (§3.1): that `externalApplicationId` already completed an application (`applicationId` echoed) | No |
| 429 | `rate_limited` | too many requests from one address (`retryAfterSec` in the body, `Retry-After` header) | Yes, after `Retry-After` |
| 424 | `nimo_rejected` | lender rejected it (`nimoStatus`, `nimo` echo the reason) | Only after fixing the flagged data |
| 424 | `nimo_auth_failed` | our lender credentials were rejected (server config), not your fault | No, contact us |
| 424 | `nimo_unreachable` | lender network/timeout error (`detail`) | Yes, with backoff |

**The most common integration mistake** is sending the fields flat, without
the `application` wrapper:

```json
// WRONG - fields at the top level
{ "product": "personal", "applicant": { … }, "loan": { … } }

// RIGHT - wrapped, with externalApplicationId BESIDE it
{ "externalApplicationId": "your-ref", "application": { "product": "personal", "applicant": { … }, "loan": { … } } }
```

The error names it rather than just saying a field is missing:
```json
{
  "error": "missing_application",
  "message": "Wrap the application fields in an 'application' object: {\"application\": { … }}. Found 'product', 'applicant', 'loan' at the top level instead. 'externalApplicationId' stays beside 'application', not inside it (…).",
  "foundAtTopLevel": ["product", "applicant", "loan"]
}
```

`422` shape (one entry per invalid field):
```json
{
  "error": "validation_failed",
  "fields": [
    { "field": "applicant.dateOfBirth", "message": "required, format YYYY-MM-DD" },
    { "field": "consents.creditCheck", "message": "must be true" }
  ]
}
```

Fields validated: `product`, `applicant.firstName`, `applicant.lastName`,
`applicant.email`, `applicant.phone`, `applicant.address`,
`applicant.dateOfBirth` (18+), `loan.amount` (> 0; personal loans: net
$8,000-$60,000), `loan.purpose`
(personal loans), `consents.creditCheck`, `consents.privacyPolicy`,
`consents.dataExchange`, `consents.creditGuide`,
`employment.employerContactNumber` (min 8 digits; required whenever
`employment.type` is a working type, i.e. anything other than
`RETIRED`/`UNEMPLOYED`, because the lender uses it to verify employment), and -
when `employment.type` is `SELF_EMPLOYED` - `business.abn`,
`business.entityName`, `business.entityType`, `business.abnActiveSince` and
`employment.averageAnnualIncome`.

`424 nimo_rejected` shape:
```json
{ "error": "nimo_rejected", "nimoStatus": 400, "nimo": { "message": "Bad request", "errors": { "error": "…reason…" } } }
```

---

## 7. Idempotency, retries & rate limits

- **Submission** (`/partner-applications`) is **not yet idempotent**. A
  retried request creates a new application, so **only retry on a non-2xx
  response** (network error, `424 nimo_unreachable`, timeout). Never retry a
  `200`.
- **Hand-off drafts** (`/partner-drafts`) ARE idempotent when you send
  `externalApplicationId` - retries return the same application (see §3.1).
- Use exponential backoff (e.g. 2s, 4s, 8s) for `nimo_unreachable`, and honour
  the `Retry-After` header on a `429`.
- Requests are throttled per source address. The limits sit far above any
  legitimate submission volume; if you expect sustained bursts of more than
  about one request per second, talk to us first and we'll size for it.
- The `Idempotency-Key` header is **reserved** for a future release; sending it
  today is accepted but has no effect.

## 8. Limitations (current build)

- **Synchronous:** the response reflects the round-trip to the lender. Set a
  client timeout of ~30s.
- **Single applicant:** joint applications (co-applicants) aren't supported yet;
  a co-applicant object would be ignored.
- **No idempotency yet.** Planned; this document will be versioned when it
  lands (see §7 for safe retry rules meanwhile).
- **No status callbacks yet:** the synchronous response is the only signal.
  There is no webhook for later status changes; quote the `reference` /
  `applicationId` when following up with us.

## 9. Support

Contact your Gamma Duo integration contact for onboarding, a UAT key, host URLs,
and to report issues (include `applicationId` and `externalApplicationId`).
