> For clean Markdown of any page, append .md to the page URL.
> For a complete documentation index, see https://docs.credit.aarthiklabs.com/llms.txt.
> For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.credit.aarthiklabs.com/_mcp/server.

# Offer Polling & Rate Limits

> Implement the Hybrid Experience offer polling cadence, Account Aggregator timing, and 429 handling.

Hybrid Experience keeps the borrower in your UI while Aarthik Labs talks to lenders. Because lender responses are asynchronous, your back-end polls the Hybrid offers endpoint until offers, an Account Aggregator action, or a no-offer outcome is ready.

The polling contract is server-owned. Your app should not invent its own timer schedule. Use the `polling.nextPoll` value returned by the API and handle `429` responses as a normal part of the integration.

```txt
GET /api/v1/hybrid/personal-loan/borrowers/{borrowerProviderID}/journey/offers
GET /api/v1/hybrid/gold-loan/borrowers/{borrowerProviderID}/journey/offers
GET /api/v1/hybrid/loan-against-mutual-funds/borrowers/{borrowerProviderID}/journey/offers
GET /api/v1/hybrid/business-loan/borrowers/{borrowerProviderID}/journey/offers
```

Business Loan responses can contain `BUSINESS_LOAN` offers and `PERSONAL_LOAN` offers suitable for self-employed borrowers. Use each offer's `productType` when displaying it. Pass the journey ID returned by Create A Journey as `journeyID` and follow the response's `polling` object. Offers may arrive at different times, so a response can contain offers while discovery is still in progress.

Hybrid product paths use the same polling object: `shouldPoll`, `lastPoll`, and `nextPoll`. Personal Loan and Business Loan use the Personal Loan-style lender-grouped offer shape in the API Reference. Gold Loan polling responses omit `data.account_aggregator` and can include lender branch locations in `data.lenders[<lenderName>].branches`. Loan Against Mutual Funds uses a LAMF-specific lender-grouped shape for Credit Line and Term Loan offers.

## What Your App Controls

Your product controls the borrower-facing wait state and decides how to present offers. Aarthik Labs controls the legal poll cadence, lender timeout windows, Account Aggregator URL readiness, and rate limits.

Your back-end should:

* call the offers endpoint from your server, not from the browser
* return the latest offer state to your front-end
* schedule the next poll from `data.polling.nextPoll`
* treat `429` as "too early, retry at the server-provided time"
* open Account Aggregator only when `data.account_aggregator.url` is non-null
* stop polling when `data.polling.shouldPoll` is `false`

## Polling Object

When offer polling is active, the response includes a `polling` object:

```json
{
  "data": {
    "status": "PROFILED",
    "polling": {
      "shouldPoll": true,
      "lastPoll": "2026-05-11T10:00:20.000Z",
      "nextPoll": "2026-05-11T10:00:30.000Z"
    },
    "lenders": {}
  },
  "error": null
}
```

| Field        | Meaning                                                                                                                                                      |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `shouldPoll` | `true` means the journey can still produce new offer state and your back-end should poll again at `nextPoll`. `false` means this polling window is complete. |
| `lastPoll`   | Server time at which the current accepted poll was processed.                                                                                                |
| `nextPoll`   | Earliest server time at which the next poll can be accepted. Polling before this time can return `429`.                                                      |

Use `nextPoll` as an absolute timestamp. Do not convert the schedule into a fixed client-side interval. If your app wakes up late, call the endpoint once and use the next `polling` object returned by the server.

## Gold Loan Branches

Gold Loan offer polling returns branch locations received from lender `on_select` responses. These branches are grouped under the lender alongside the offer rows.

```json
{
  "data": {
    "status": "OFFERED",
    "lenders": {
      "Aurum Bank": {
        "minimumLoanAmount": "50000.00",
        "maximumLoanAmount": "5000000.00",
        "branches": [
          {
            "id": "L1",
            "gps": "28.574909, 77.194029",
            "name": "Mumbai Central",
            "address": "123 Marine Drive, Mumbai, Maharashtra",
            "pincode": "400001"
          }
        ],
        "offers": [
          {
            "consentHandleAvailable": false,
            "annualPercentageRate": "5.00",
            "installmentAmount": "88460.00",
            "ltvRatio": null,
            "status": "OFFER_RECEIVED",
            "offerID": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
            "amountPrincipal": "400000",
            "interestRate": "12.00",
            "offerTerm": "5 months",
            "processingFee": "1100.00",
            "applicationFee": "1000.00",
            "offerValidity": "15 days",
            "ondcTransactionID": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
            "offerSignals": {
              "offerType": "FINAL_OFFER",
              "fulfillmentJourneyType": "COMBINED_ONLINE_OFFLINE",
              "conversionChance": "VERY_HIGH"
            }
          }
        ]
      }
    },
    "polling": {
      "shouldPoll": true,
      "lastPoll": "1970-01-01T00:00:20.000Z",
      "nextPoll": "1970-01-01T00:00:30.000Z"
    }
  },
  "error": null
}
```

## Loan Against Mutual Funds Offer Types

Loan Against Mutual Funds polling supports two offer types: Credit Lines and Term Loans. LAMF lender groups intentionally do not include `minimumLoanAmount` or `maximumLoanAmount`.

| LAMF offer type | Includes                                                                                                   | Does not include                                   |
| --------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| Credit Line     | `productType`, `facilitytype`, `sanctionedLimit`, `renewalFrequency`, fees, validity, pledge requirements  | `amountPrincipal`, `installmentAmount`, `ltvRatio` |
| Term Loan       | `productType`, `facilitytype`, `amountPrincipal`, `installmentAmount`, fees, validity, pledge requirements | `sanctionedLimit`, `renewalFrequency`, `ltvRatio`  |

For both LAMF Credit Line and Term Loan offers, `productType` is `LAMF`. `facilitytype` is `CREDIT_LINE` or `TERM_LOAN` depending on the offer. LAMF offer signals are documented as `FINAL_OFFER`, `FULLY_DIGITAL_STP`, and `VERY_HIGH`.

## Offer Signals

When lender signal configuration is available, each offer row can include `offerSignals`. These signals are borrower-facing display hints only. They should be shown as badges or supporting labels, not used to reorder lenders or auto-select an offer.

| Field                    | Values                                                             |
| ------------------------ | ------------------------------------------------------------------ |
| `offerType`              | `DEDUPE_OFFER`, `PROVISIONAL_OFFER`, `FINAL_OFFER`                 |
| `fulfillmentJourneyType` | `FULLY_DIGITAL_STP`, `COMBINED_ONLINE_OFFLINE`, `OFFLINE_ASSISTED` |
| `conversionChance`       | `VERY_HIGH`, `HIGH`, `MODERATE`, `LOW`, `VERY_LOW`                 |

## Polling Phases

Hybrid offer polling has two phases.

```mermaid
flowchart TD
  A["Journey starts and offer fetch is accepted"] --> B["Pre-AA / non-AA window"]
  B --> C{"AA URL ready?"}
  C --> D["Open Account Aggregator URL"]
  D --> E["Borrower completes AA"]
  E --> F["Post-AA window"]
  B --> G["Non-AA offers or no-offer outcome"]
  F --> H["AA-backed offers or no-offer outcome"]
```

### Pre-AA And Non-AA Window

This window starts when the Hybrid journey starts offer fetch.

Default accepted poll slots:

| Slot | Time after offer fetch starts | What can happen                                                                                                                     |
| ---- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| 1    | `+20s`                        | First accepted read for non-AA offers and early AA readiness.                                                                       |
| 2    | `+30s`                        | Account Aggregator URL can be returned if all required consent handles are ready. AA lenders missing consent handles are timed out. |
| 3    | `+40s`                        | Continue reading non-AA lender responses.                                                                                           |
| 4    | `+50s`                        | Continue reading non-AA lender responses.                                                                                           |
| 5    | `+60s`                        | Final pre-window poll. Non-AA lenders still awaiting are timed out.                                                                 |

If at least one offer is available, your UI can show it immediately. If `polling.shouldPoll` remains `true`, you may keep refreshing at `nextPoll` to pick up additional lender outcomes, but you should not block the borrower from seeing available offers unless your own product policy requires that.

### Account Aggregator URL Timing

The Account Aggregator URL is intentionally not returned immediately when offer fetch starts. This section applies when the offers response includes `data.account_aggregator`. Gold Loan Hybrid does not return Account Aggregator in the current flow.

By default, Aarthik Labs waits until the second pre-window poll slot, `+30s`, before returning the AA URL. This gives lenders time to share the required consent handles.

When AA is applicable, the response can include:

```json
{
  "data": {
    "account_aggregator": {
      "url": null,
      "expires_at": null,
      "lenders": ["AA Bank"],
      "productType": "BUSINESS_LOAN"
    }
  }
}
```

Use this object as follows:

| Shape                          | Meaning                                               | What your app should do                                                                      |
| ------------------------------ | ----------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `account_aggregator` is absent | No borrower-facing AA action is currently applicable. | Continue rendering offers, waiting states, or terminal states from the rest of the response. |
| `url` is `null`                | AA is applicable, but the URL is not ready yet.       | Keep the borrower in a wait state and poll again at `polling.nextPoll`.                      |
| `url` is a string              | A short-lived AA URL is ready.                        | Ask the borrower to continue with consent and open the returned URL immediately.             |

The `account_aggregator` object only contains:

* `url`
* `expires_at`
* `lenders`
* `productType` for Business Loan journeys identifies the product associated with the current action; continue using the same `journeyID`

It does not contain `status`, `available`, or `poll_after_ms`. The `polling` object is the source of truth for when to poll again.

### Post-AA Window

The post-AA window starts after the borrower completes the Account Aggregator flow and returns to the app.

Default accepted poll slots:

| Slot | Time after post-AA offer fetch starts | What can happen                                              |
| ---- | ------------------------------------- | ------------------------------------------------------------ |
| 1    | `+30s`                                | First accepted read for AA-backed offers.                    |
| 2    | `+45s`                                | Continue reading AA-backed lender responses.                 |
| 3    | `+60s`                                | Continue reading AA-backed lender responses.                 |
| 4    | `+75s`                                | Continue reading AA-backed lender responses.                 |
| 5    | `+90s`                                | Final post-AA poll. AA lenders still awaiting are timed out. |

After the final post-AA poll, `polling.shouldPoll` becomes `false` for that window.

## Total Accepted Polls

In the default full AA journey, there can be up to ten accepted polls:

* five pre-AA / non-AA accepted polls
* five post-AA accepted polls

Additional calls before the next legal slot are not accepted. They return `429` and do not count as successful offer reads.

## Rate Limits

Polling faster than `polling.nextPoll` can return:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 10
X-Aarthik-Next-Poll-At: 2026-05-11T10:00:30.000Z
```

```json
{
  "data": {
    "polling": {
      "shouldPoll": true,
      "lastPoll": "2026-05-11T10:00:20.000Z",
      "nextPoll": "2026-05-11T10:00:30.000Z"
    }
  },
  "error": {
    "code": "OFFER_POLL_RATE_LIMITED",
    "message": "Offer polling is rate limited. Please poll again at 2026-05-11T10:00:30.000Z.",
    "retryable": true,
    "details": {
      "polling": {
        "shouldPoll": true,
        "lastPoll": "2026-05-11T10:00:20.000Z",
        "nextPoll": "2026-05-11T10:00:30.000Z"
      }
    }
  }
}
```

When you receive `429`:

* do not show it to the borrower as a failure
* wait until `X-Aarthik-Next-Poll-At` or `error.details.polling.nextPoll`
* use `Retry-After` as a fallback if timestamp scheduling is not available
* keep the current borrower wait state or currently visible offers
* retry the same offers endpoint once the server-provided time arrives

## Back-End Polling Loop

Use a single scheduled poll per borrower journey. Avoid multiple browser tabs, mobile retries, and background jobs all polling the platform independently.

```ts
type Polling = {
  shouldPoll: boolean;
  lastPoll: string;
  nextPoll: string;
};

type HybridProduct =
  | "personal-loan"
  | "gold-loan"
  | "loan-against-mutual-funds"
  | "business-loan";

async function pollHybridOffers(
  product: HybridProduct,
  borrowerProviderID: string,
) {
  const response = await fetch(
    `${PLATFORM_BASE_URL}/api/v1/hybrid/${product}/borrowers/${borrowerProviderID}/journey/offers`,
    {
      headers: {
        Authorization: `Bearer ${PLATFORM_API_KEY}`,
      },
    },
  );

  const body = await response.json();

  if (response.status === 429) {
    const polling = body.error?.details?.polling ?? body.data?.polling;
    scheduleNextPoll(borrowerProviderID, polling.nextPoll);
    return { rateLimited: true, polling };
  }

  if (!response.ok) {
    throw new Error(body.error?.message ?? "Unable to poll offers.");
  }

  const polling = body.data?.polling as Polling | undefined;
  if (polling?.shouldPoll) {
    scheduleNextPoll(borrowerProviderID, polling.nextPoll);
  }

  return body.data;
}
```

The important behavior is simple: schedule from the server response, not from a hardcoded interval.

## UI Behavior

Your UI can use the offer response to choose the current borrower state.

| Response state                                                        | UI behavior                                                                                                                          |
| --------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| One or more `OFFER_RECEIVED` rows                                     | Show available offers. Continue refreshing only if `polling.shouldPoll` is still `true` and your product wants later lender updates. |
| `account_aggregator.url` is a string                                  | Show a clear AA consent action and open the URL when the borrower continues.                                                         |
| `account_aggregator.url` is `null` and `polling.shouldPoll` is `true` | Show a wait state and poll again at `polling.nextPoll`.                                                                              |
| All lenders are `NO_OFFER_AVAILABLE`                                  | Show a no-offer state.                                                                                                               |
| `polling.shouldPoll` is `false`                                       | Stop automatic polling for this window.                                                                                              |

## Configuration

The default schedule can be configured per application by Aarthik Labs.

| Config field                      | Default | Meaning                                                 |
| --------------------------------- | ------: | ------------------------------------------------------- |
| `polling_pre_timeout`             |    `60` | Pre-AA / non-AA timeout window in seconds.              |
| `polling_pre_initial_time`        |    `20` | First accepted pre-window poll in seconds.              |
| `polling_pre_subsequent_cadence`  |    `10` | Cadence for later pre-window accepted polls in seconds. |
| `polling_post_timeout`            |    `90` | Post-AA timeout window in seconds.                      |
| `polling_post_initial_time`       |    `30` | First accepted post-AA poll in seconds.                 |
| `polling_post_subsequent_cadence` |    `15` | Cadence for later post-AA accepted polls in seconds.    |

These fields are application-level platform configuration. Integrating partners should still follow the `polling.nextPoll` value returned by the API because the effective schedule can differ by application.

## Practical Rules

* Poll from your back-end only.
* Use `polling.nextPoll` for the next attempt.
* Treat `429` as a normal timing response, not an integration failure.
* Do not use `poll_after_ms`; it is not part of the current Hybrid polling contract.
* Do not infer AA readiness from lender names alone. Use `account_aggregator.url`.
* For Gold Loan, do not wait for Account Aggregator. Render offers or waiting states from `status`, `polling`, and `lenders`.
* Do not keep polling after `polling.shouldPoll` becomes `false`.
* Do not store Account Aggregator URLs as long-term resume links.
* Log `borrowerProviderID`, journey ID, status, `polling.nextPoll`, and whether `account_aggregator.url` was present for support.

## Checklist

Before going live, confirm that your integration:

* starts polling only after the journey start call succeeds
* schedules all follow-up polls from `polling.nextPoll`
* handles `429` by waiting and retrying at the returned time
* shows available offers while later lender responses may still be pending
* opens AA only when `account_aggregator.url` is present
* does not expect `account_aggregator` on Gold Loan
* refreshes offers after the borrower returns from AA
* stops automatic polling when `polling.shouldPoll` is `false`
* avoids duplicate poll loops for the same borrower journey