# Plaid transaction format

`plaid-txn-stream` is the only publicly documented file content contract. Upload UTF-8 JSON with `fileType=json` and `Content-Type: application/json`.

The payload must be an object containing a `transactions` array and an `accounts` array, joined by `account_id`. Every distinct `account_id` referenced by a transaction must be present in `accounts`; partial account coverage fails validation for the entire file.

## Recommended envelope

```json
{
  "accounts": [
    {
      "account_id": "acc_EXAMPLE_abc123xyz",
      "mask": "4321",
      "name": "Freedom Unlimited",
      "official_name": "CHASE FREEDOM UNLIMITED VISA SIGNATURE",
      "type": "credit",
      "subtype": "credit card",
      "balances": {
        "available": 1234.56,
        "current": 1234.56,
        "iso_currency_code": "USD"
      }
    }
  ],
  "transactions": [
    {
      "transaction_id": "plaid-transaction-1",
      "percentsMerchantId": "9b1f2f48-eaf2-4c22-9a49-76d32f942183",
      "segment": "loyal",
      "pending_transaction_id": null,
      "account_id": "acc_EXAMPLE_abc123xyz",
      "amount": 4.75,
      "iso_currency_code": "USD",
      "date": "2026-07-11",
      "datetime": "2026-07-11T14:32:07Z",
      "authorized_date": "2026-07-10",
      "authorized_datetime": "2026-07-10T18:04:12Z",
      "name": "BLUE BOTTLE COFFEE #142 OAKLAND CA",
      "merchant_name": "Blue Bottle Coffee",
      "merchant_entity_id": "mrch_ent_EXAMPLE_bluebottle",
      "payment_channel": "in store",
      "pending": false,
      "category": ["Food and Drink", "Restaurants", "Coffee Shop"],
      "personal_finance_category": {
        "primary": "FOOD_AND_DRINK",
        "detailed": "FOOD_AND_DRINK_COFFEE",
        "confidence_level": "VERY_HIGH"
      },
      "location": {
        "city": "Oakland",
        "region": "CA",
        "postal_code": "94607",
        "country": "US"
      }
    }
  ]
}
```

## Fields used for normalization

| Field | Requirement and behavior |
|  --- | --- |
| `transaction_id` | Required, non-empty unique transaction identifier |
| `percentsMerchantId` | Required UUID supplied by Percents; identifies the Omni merchant independently of Plaid's merchant text and IDs |
| `segment` | Required; one of `new`, `loyal`, `lapsed`, or JSON `null` when the cardholder has no segment |
| `account_id` | Required; 100% of transaction account IDs must match an entry in the top-level `accounts` array |
| `amount` | Required finite number in Plaid transaction units; do not convert dollars to cents |
| `datetime`, `authorized_datetime`, `date`, `authorized_date` | At least one valid value; Percents uses the first available in this order |
| `merchant_name` or `name` | At least one required; `merchant_name` is preferred as canonical merchant name |
| `name` | Used as the transaction descriptor when present |
| `merchant_entity_id` | Used as the network merchant ID when present |
| `iso_currency_code` | Defaults to `USD` when absent |
| account `mask` | Required on the matching account |
| account `name`, `official_name`, `subtype`, `type` | Optional card/account metadata |
| `payment_channel` | `in store` maps to `chip`; `online` maps to `online`; other values remain in the raw payload without a mapped auth method |


Additional Plaid fields are allowed and preserved with the raw transaction for audit and troubleshooting. Do not remove fields from a standard Plaid response merely because Percents does not currently normalize them.

Every transaction in a file must contain the custom `percentsMerchantId` and `segment` fields. Percents rejects the complete file before ingestion when a merchant UUID is unknown, a segment is missing or invalid, or any other row fails the public contract.

On validation failure, the `FileUploadSchemaValidation` webhook returns every missing account ID in `errorMetadata.missingAccountIds` and every unknown merchant UUID in `errorMetadata.unknownPercentsMerchantIds`.