# Bank & Financial Data

Partners with an existing Plaid or Stripe Financial Connections integration can forward the applicant's bank data directly to Quantum for cash flow analysis. You submit the vendor's own objects, wrapped in a small envelope that names the source — Quantum handles normalization, sign conversion, and field mapping internally.

## Table of Contents

- [Overview](#overview)
- [Plaid Asset Report](#plaid-asset-report)
- [Stripe Financial Connections](#stripe-financial-connections)
- [Response](#response)

---

## Overview

{% endpoint method="POST" path="/api/v3/applications/{app_id}/transactions" /%}

{% parameter name="app_id" type="string (UUID)" required=true /%}

**Authentication:** Bearer token in the `Authorization` header.

The request body is an envelope with two fields. `type` names the source, and `data` carries the vendor data for that source:

```json
{
  "type": "plaid_asset_report",
  "data": { "...": "vendor data" }
}
```

| `type` | Source | What goes in `data` |
|--------|--------|---------------------|
| `plaid_asset_report` | Plaid [Asset Report](https://plaid.com/docs/api/products/assets/) | The `report` object from Plaid's `/asset_report/get` response |
| `stripe_financial_connections` | [Stripe Financial Connections](https://docs.stripe.com/financial-connections) | `{ "accounts": [...] }` — every connected account, each carrying its complete transaction list |

`type` is required. A request without it, or with a value not listed above, is rejected with `422`.

{% callout type="info" %}
Send the vendor's response as-is. Fields beyond those documented here are accepted, so there is nothing to strip or reshape. Values must be valid JSON — `NaN` and `Infinity` are rejected.
{% /callout %}

#### History requirement

{% callout type="warning" %}
**Cash flow analysis is built on roughly three months of transaction history.** Submit at least 90 days of transactions for every account; 6–12 months is ideal where the institution provides it. Data covering less than 90 days may produce an incomplete or failed analysis.
{% /callout %}

---

## Plaid Asset Report

**Use this type** when your integration uses Plaid's [Asset Reports](https://plaid.com/docs/api/products/assets/) product.

#### Data requirements

- Set `days_requested` to at least 90 when creating the report via Plaid's `/asset_report/create` (see [History requirement](#history-requirement)).
- The report must contain at least one item, and at least one account across its items. An empty report is rejected with `422`.
- `asset_report_id`, `date_generated`, and `items` are required. Within each item, `item_id` and `accounts` are required. Each transaction needs `transaction_id`, `account_id`, `amount`, and `date`. Plaid always populates these.

#### Sign convention

Plaid's standard convention applies: **positive amount = money leaving the account** (debit), negative amount = money entering the account (credit). Pass values exactly as Plaid returns them — Quantum normalizes internally.

#### How to submit

Pass the `report` object from Plaid's `/asset_report/get` response as `data` — specifically `response.report`, not the full API response wrapper.

```javascript
// 1. Fetch the asset report from Plaid (you may already have this from a webhook)
const plaidResponse = await plaidClient.assetReportGet({
  asset_report_token: assetReportToken
});

// 2. Forward the report object to Quantum
const response = await fetch(
  `https://api.quantum.com/api/v3/applications/${applicationId}/transactions`,
  {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer YOUR_API_TOKEN',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      type: 'plaid_asset_report',
      data: plaidResponse.data.report  // the raw report object, unchanged
    })
  }
);
```

{% callout type="warning" %}
Each asset report can be ingested once. Submitting a report whose `asset_report_id` has already been ingested — for this application or any other — returns `409 Conflict`. Create a fresh asset report for each application rather than reusing one.
{% /callout %}

#### Example payload

```json
{
  "type": "plaid_asset_report",
  "data": {
    "asset_report_id": "4d74e6a8-4d74-4e6a-8c4d-74e6a84d74e6",
    "client_report_id": "app-12345",
    "date_generated": "2026-06-29T12:00:00Z",
    "days_requested": 180,
    "items": [
      {
        "item_id": "eVBnVMp7zdTJLkRNr33Rs6zr7KRqjBduiatePb",
        "institution_id": "ins_3",
        "institution_name": "Chase",
        "date_last_updated": "2026-06-29T11:58:00Z",
        "accounts": [
          {
            "account_id": "BxBXxLj1m4HMXBm9WZJyUg9XLd4rKEhw8Pb1J",
            "mask": "4321",
            "name": "Business Checking",
            "official_name": "CHASE BUSINESS COMPLETE CHECKING",
            "subtype": "checking",
            "type": "depository",
            "days_available": 180,
            "balances": {
              "available": 43821.06,
              "current": 43821.06,
              "iso_currency_code": "USD",
              "limit": null
            },
            "historical_balances": [
              { "date": "2026-06-28", "current": 41250.00, "iso_currency_code": "USD" },
              { "date": "2026-06-27", "current": 38900.50, "iso_currency_code": "USD" },
              { "date": "2026-06-26", "current": 44120.75, "iso_currency_code": "USD" }
            ],
            "transactions": [
              {
                "transaction_id": "lPNjeW1nR6CDn5okmGQ6hEpMo4lLNoSrzqDje",
                "account_id": "BxBXxLj1m4HMXBm9WZJyUg9XLd4rKEhw8Pb1J",
                "amount": -2307.21,
                "iso_currency_code": "USD",
                "date": "2026-06-17",
                "original_description": "STRIPE PAYOUT ST-ABCD1234EFGH5678",
                "pending": false
              },
              {
                "transaction_id": "PPKaXNobm1uRPQNfqbFcsk3JNbKp1qJLXbDrE",
                "account_id": "BxBXxLj1m4HMXBm9WZJyUg9XLd4rKEhw8Pb1J",
                "amount": 450.00,
                "iso_currency_code": "USD",
                "date": "2026-06-14",
                "original_description": "COMCAST BUSINESS 800-391-3000 IL",
                "pending": false
              }
            ]
          }
        ]
      }
    ],
    "user": {
      "client_user_id": "user-12345",
      "first_name": "Jane",
      "last_name": "Smith",
      "email": "jane@acmecorp.com"
    }
  }
}
```

---

## Stripe Financial Connections

**Use this type** when your integration uses [Stripe Financial Connections](https://docs.stripe.com/financial-connections).

Stripe has no report object. A Plaid asset report is complete by construction — one artifact, one id — but Stripe exposes an account, a balance, and a paginated transaction list, each refreshed on its own schedule. Assembling them into one submission is your job. This section describes exactly what Quantum expects, because a payload assembled by hand is easy to get subtly wrong: the result is either rejected or, worse, analysed with an incomplete picture of the applicant.

#### Payload shape

`data` has a single field, `accounts`: an array with one entry per connected account. Each entry is Stripe's [Account object](https://docs.stripe.com/api/financial_connections/accounts/object), exactly as `GET /v1/financial_connections/accounts/{id}` returns it, with one field added — `transactions`, holding Stripe's transaction **list object** for that account with every page concatenated into `data`.

```json
{
  "type": "stripe_financial_connections",
  "data": {
    "accounts": [
      {
        "id": "fca_...",
        "object": "financial_connections.account",
        "balance": { "as_of": 1787087640, "current": { "usd": 2927625 }, "...": "..." },
        "transactions": {
          "object": "list",
          "has_more": false,
          "data": [ { "id": "fctxn_...", "amount": 289000, "currency": "usd", "status": "posted", "...": "..." } ]
        },
        "...": "every other Account field, untouched"
      }
    ]
  }
}
```

Three parts of this shape are routinely lost when exporting by hand, and each one is checked:

- **Keep the list wrapper on `transactions`.** A bare array is rejected. Unwrapping it loses `has_more`, the only completeness signal Stripe gives.
- **`has_more` must be `false`.** Page the account's transactions until Stripe reports `has_more: false`, concatenate the pages, and set `has_more: false` on the assembled list. A single page submitted as a whole history produces a confident wrong analysis rather than an error.
- **`balance.current` is a map, not a number.** Stripe keys balances by lowercase ISO currency code — `{ "usd": 2927625 }`. Leave it that way.

Do not otherwise reshape the account: do not hoist `balance` or `owners` to the top level, do not add annotations of your own, and leave amounts and timestamps exactly as Stripe returned them. Fields Quantum does not use — `ownership`, `permissions`, `livemode`, `balance_refresh`, `transaction_refresh`, `account_holder`, `subscriptions`, and so on — are accepted and ignored, so there is nothing to strip.

#### All accounts in one submission

{% callout type="error" %}
**One submission must carry every account the applicant connected, and Quantum cannot check that it does.** Quantum has no way to know how many accounts the applicant connected in your Stripe session. A submission containing one valid account from a multi-account session is accepted with `200` and analysed as if it were the applicant's complete financial picture. No error, then or later, will tell you it was incomplete — the only symptom is a lending decision made on partial data. Collect every account from the session before you submit.
{% /callout %}

Because Stripe has no report id, Quantum identifies a submission by a content hash over its accounts. Re-sending an identical submission for the same application returns `409 Conflict` — it carries nothing new. Re-pull the accounts and resubmit once a balance or transaction has changed.

A submission with a *different* set of accounts is not a duplicate. It is accepted and produces a separate analysis, so sending accounts one at a time, or adding a forgotten account in a follow-up request, does not merge them into one picture. Each request stands alone.

#### Balance is required, and the `balances` permission does not populate it

{% callout type="warning" %}
**Every account must carry a populated `balance` with a non-empty `balance.current`.** Being granted the `balances` permission does not fill it in. Balance and transaction refreshes are independent, and `subscriptions` covers transactions only — an account that is subscribed to transactions and has `permissions: ["balances", "transactions"]` can still have `balance: null` indefinitely. Before submitting, either include `balances` in `prefetch` when you create the Financial Connections session, or call `POST /v1/financial_connections/accounts/{id}/refresh` with `features[]=balances` and wait until `balance_refresh.status` is `succeeded`.
{% /callout %}

Quantum anchors the running-balance walk in cash flow analysis on the current balance, so an account without one cannot be analysed. Rather than checking permissions, Quantum checks that the data is actually present.

#### Sign convention and units

Stripe amounts are **integers in the smallest currency unit** (cents for USD) and use the **opposite sign to Plaid**. Stripe's Account object documents the orientation for its balance maps: *"A positive amount indicates money owed to the account holder. A negative amount indicates money owed by the account holder."* Transactions follow the same account-holder perspective — **positive = money coming into the account**, negative = money leaving it. Pass values exactly as Stripe returns them: do not convert to decimal currency units and do not invert the sign. Quantum normalizes internally.

#### Transaction statuses

Every transaction's `status` must be one of Stripe's three values: `posted`, `pending`, or `void`. Each account must have at least one `posted` transaction. `void` rows are accepted and then dropped; `pending` rows are ingested and flagged as pending. `status_transitions.posted_at` is used as the transaction date when present, falling back to `transacted_at`.

#### Validation

Quantum validates the whole submission before anything is stored, and validation is all-or-nothing: one unusable account rejects the entire submission with `422`. The response lists every problem found, so a single round trip tells you everything to fix. Accounts and transactions are identified by their position in the arrays you submitted, never by id.

These rules check each account you submit. Nothing checks that you submitted all of them — completeness is your responsibility (see [All accounts in one submission](#all-accounts-in-one-submission)).

| Rule | Message when violated |
|------|-----------------------|
| Every account has a `balance` | `accounts[2]: carries no balance` |
| `balance.current` has at least one currency entry | `accounts[2]: carries a balance with no amount in it` |
| `transactions.has_more` is `false` | `accounts[2]: reports has_more=true, so this is a page not the whole history` |
| At least one `posted` transaction per account | `accounts[2]: has no posted transactions` |
| Every `status` is `posted`, `pending`, or `void` | `accounts[2]: has transactions with an unrecognised status (transactions.data[7])` |
| One currency per account, across balance and non-void transactions | `accounts[2]: mixes currencies` |
| One currency across the whole submission | `submission spans currencies` |
| No account appears twice | `accounts[3]: is the same account as accounts[0]` |

A rejected submission looks like this:

```json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Some fields are missing or invalid.",
    "details": [
      { "field": "data.accounts", "message": "accounts[1]: carries no balance" },
      { "field": "data.accounts", "message": "accounts[1]: reports has_more=true, so this is a page not the whole history" }
    ]
  }
}
```

#### How to submit

```javascript
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);

// Poll until the account's balance refresh has completed. Balances are only
// populated by a balance refresh — a transactions subscription never fills them.
async function ensureBalance(accountId) {
  let account = await stripe.financialConnections.accounts.retrieve(accountId);
  if (account.balance) return account;

  await stripe.financialConnections.accounts.refresh(accountId, { features: ['balances'] });
  do {
    await new Promise((r) => setTimeout(r, 2000));
    account = await stripe.financialConnections.accounts.retrieve(accountId);
  } while (account.balance_refresh?.status === 'pending');

  if (!account.balance) {
    throw new Error(`Balance refresh failed for ${accountId}: ${account.balance_refresh?.status}`);
  }
  return account;
}

// Page through every transaction for the account and rebuild Stripe's list object.
async function fetchAllTransactions(accountId) {
  const data = [];
  let startingAfter;
  let hasMore = true;

  while (hasMore) {
    const page = await stripe.financialConnections.transactions.list({
      account: accountId,
      limit: 100,
      ...(startingAfter && { starting_after: startingAfter })
    });
    data.push(...page.data);
    hasMore = page.has_more;
    if (hasMore) startingAfter = page.data[page.data.length - 1].id;
  }

  return {
    object: 'list',
    url: '/v1/financial_connections/transactions',
    has_more: false,   // every page has been fetched
    data
  };
}

// 1. Collect every account the applicant connected in the Financial Connections session
const session = await stripe.financialConnections.sessions.retrieve(sessionId);
const accountIds = session.accounts.data.map((a) => a.id);

// 2. For each account: confirm the balance is populated, then attach its full transaction list
const accounts = [];
for (const accountId of accountIds) {
  const account = await ensureBalance(accountId);
  account.transactions = await fetchAllTransactions(accountId);
  accounts.push(account);   // the raw Account object plus `transactions`, nothing else changed
}

// 3. Submit all accounts together in one request
const response = await fetch(
  `https://api.quantum.com/api/v3/applications/${applicationId}/transactions`,
  {
    method: 'POST',
    headers: {
      'Authorization': 'Bearer YOUR_API_TOKEN',
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      type: 'stripe_financial_connections',
      data: { accounts }
    })
  }
);
```

{% callout type="info" %}
If you request `prefetch: ['balances', 'transactions']` when creating the Financial Connections session, the balance is already populated when the applicant finishes connecting and the refresh step above is a no-op.
{% /callout %}

#### Example payload

Two accounts at one institution. The transaction lists are truncated for readability — a real submission carries the account's full history (see [History requirement](#history-requirement)).

```json
{
  "type": "stripe_financial_connections",
  "data": {
    "accounts": [
      {
        "id": "fca_1UaQ7k2RIoLNqLiBqTvE83Kd",
        "object": "financial_connections.account",
        "account_holder": {
          "customer": "cus_UaQ7kR2VtMbNqL",
          "type": "customer"
        },
        "balance": {
          "as_of": 1787087640,
          "type": "cash",
          "current": { "usd": 2927625 },
          "cash": {
            "available": { "usd": 2740125 }
          }
        },
        "balance_refresh": {
          "last_attempted_at": 1787087640,
          "next_refresh_available_at": 1787109240,
          "status": "succeeded"
        },
        "category": "cash",
        "subcategory": "checking",
        "display_name": "Business Complete Checking",
        "institution_name": "Cedar Ridge Bank",
        "last4": "7742",
        "livemode": true,
        "permissions": ["balances", "ownership", "transactions"],
        "status": "active",
        "subscriptions": ["transactions"],
        "transaction_refresh": {
          "id": "fctxnref_1UaQ7j2RIoLNqLiBz3Kd8vTq",
          "last_attempted_at": 1787562720,
          "next_refresh_available_at": 1787584320,
          "status": "succeeded"
        },
        "transactions": {
          "object": "list",
          "url": "/v1/financial_connections/transactions",
          "has_more": false,
          "data": [
            {
              "id": "fctxn_1UUh65UgPwHp7FUOmFg7WBS3",
              "object": "financial_connections.transaction",
              "account": "fca_1UaQ7k2RIoLNqLiBqTvE83Kd",
              "amount": 187500,
              "currency": "usd",
              "description": "ACH CREDIT CUSTOMER DEPOSIT",
              "status": "pending",
              "status_transitions": { "posted_at": null, "void_at": null },
              "transacted_at": 1787511900,
              "transaction_refresh": "fctxnref_1UaQ7j2RIoLNqLiBz3Kd8vTq",
              "livemode": true,
              "updated": 1787512000
            },
            {
              "id": "fctxn_1UmQwpOp6LhYpt0a30Fk5eno",
              "object": "financial_connections.transaction",
              "account": "fca_1UaQ7k2RIoLNqLiBqTvE83Kd",
              "amount": -167500,
              "currency": "usd",
              "description": "NORTHSIDE PACKAGING ACH DEBIT",
              "status": "posted",
              "status_transitions": { "posted_at": 1787409000, "void_at": null },
              "transacted_at": 1787339100,
              "transaction_refresh": "fctxnref_1UaQ7j2RIoLNqLiBz3Kd8vTq",
              "livemode": true,
              "updated": 1787409100
            },
            {
              "id": "fctxn_1UDpChirA7V0f8aMemDaYJsz",
              "object": "financial_connections.transaction",
              "account": "fca_1UaQ7k2RIoLNqLiBqTvE83Kd",
              "amount": -98000,
              "currency": "usd",
              "description": "CARD REFUND REVERSED",
              "status": "void",
              "status_transitions": { "posted_at": null, "void_at": 1787236200 },
              "transacted_at": 1787166300,
              "transaction_refresh": "fctxnref_1UaQ7j2RIoLNqLiBz3Kd8vTq",
              "livemode": true,
              "updated": 1787236300
            },
            {
              "id": "fctxn_1UPRhwLxKJWNpaIgjOFNIjiq",
              "object": "financial_connections.transaction",
              "account": "fca_1UaQ7k2RIoLNqLiBqTvE83Kd",
              "amount": 289000,
              "currency": "usd",
              "description": "ACH CREDIT CUSTOMER DEPOSIT",
              "status": "posted",
              "status_transitions": { "posted_at": 1787149800, "void_at": null },
              "transacted_at": 1787079900,
              "transaction_refresh": "fctxnref_1UaQ7j2RIoLNqLiBz3Kd8vTq",
              "livemode": true,
              "updated": 1787149900
            },
            {
              "id": "fctxn_1UAq3xPz9RkLmn2cD4Ef7gHj",
              "object": "financial_connections.transaction",
              "account": "fca_1UaQ7k2RIoLNqLiBqTvE83Kd",
              "amount": -45000,
              "currency": "usd",
              "description": "COMCAST BUSINESS 800-391-3000",
              "status": "posted",
              "status_transitions": { "posted_at": 1778086800, "void_at": null },
              "transacted_at": 1778000400,
              "transaction_refresh": "fctxnref_1UaQ7j2RIoLNqLiBz3Kd8vTq",
              "livemode": true,
              "updated": 1778086900
            }
          ]
        }
      },
      {
        "id": "fca_1UbR8m2RIoLNqLiBwXyF94Le",
        "object": "financial_connections.account",
        "account_holder": {
          "customer": "cus_UaQ7kR2VtMbNqL",
          "type": "customer"
        },
        "balance": {
          "as_of": 1787087700,
          "type": "cash",
          "current": { "usd": 811400 },
          "cash": {
            "available": { "usd": 811400 }
          }
        },
        "balance_refresh": {
          "last_attempted_at": 1787087700,
          "next_refresh_available_at": 1787109300,
          "status": "succeeded"
        },
        "category": "cash",
        "subcategory": "savings",
        "display_name": "Business Savings",
        "institution_name": "Cedar Ridge Bank",
        "last4": "3318",
        "livemode": true,
        "permissions": ["balances", "ownership", "transactions"],
        "status": "active",
        "subscriptions": ["transactions"],
        "transaction_refresh": {
          "id": "fctxnref_1UbR8l2RIoLNqLiBx4Mf9wUr",
          "last_attempted_at": 1787562780,
          "next_refresh_available_at": 1787584380,
          "status": "succeeded"
        },
        "transactions": {
          "object": "list",
          "url": "/v1/financial_connections/transactions",
          "has_more": false,
          "data": [
            {
              "id": "fctxn_1UcT2qNr8VkXpz4bB1Hm6yWd",
              "object": "financial_connections.transaction",
              "account": "fca_1UbR8m2RIoLNqLiBwXyF94Le",
              "amount": 250000,
              "currency": "usd",
              "description": "TRANSFER FROM CHECKING",
              "status": "posted",
              "status_transitions": { "posted_at": 1786923000, "void_at": null },
              "transacted_at": 1786923000,
              "transaction_refresh": "fctxnref_1UbR8l2RIoLNqLiBx4Mf9wUr",
              "livemode": true,
              "updated": 1786923100
            }
          ]
        }
      }
    ]
  }
}
```

---

## Response

A successful submission returns HTTP `200` with the `type` you submitted echoed back:

```json
{
  "type": "stripe_financial_connections",
  "message": "Stripe Financial Connections data ingested"
}
```

For Plaid the message is `"Plaid asset report ingested"`.

Processing is asynchronous — Quantum runs cash flow analysis in the background. Monitor progress via `GET /api/v3/applications/{app_id}/status` or listen for status webhooks.

### Error responses

| Status | Meaning |
|--------|---------|
| `403` | Invalid or missing API token, or the token is not authorized to submit transaction data |
| `404` | Application ID not found, or it does not belong to your partner account |
| `409` | Plaid: this `asset_report_id` has already been ingested. Stripe: this submission is identical to one already ingested for this application |
| `422` | Payload failed validation — missing or unknown `type`, missing required fields, wrong field types, or (Stripe) an unanalysable account. The `details` array names every problem |
| `510` | Unexpected error on Quantum's side. Retry later; if it persists, contact support |

For complete request and response schemas, see the [API Reference](/api-reference.html).
