Round Up

Round-Up rounds each eligible transaction up to the next whole dollar (or a fixed/boosted amount you configure) and, on a fixed cadence, sweeps the accumulated round-ups from a funding account into a destination account.

This guide explains how Round-Up works and walks through testing the entire flow end-to-end in sandbox: link an external bank, inject synthetic transactions with the linked-institution simulation endpoints, and observe round-ups accrue and sweep, without moving real money.

Round-Up can track two kinds of accounts:

  • Spidr accounts: a card/deposit account issued on your Spidr program.
  • Linked institution accounts: an external bank the user connected via Plaid (transaction aggregation).

This guide focuses on the linked institution path, because the simulation endpoints let you drive it entirely from the API.


Quick Start: the sandbox test loop

  1. Link an external bank for transaction aggregation:
    session/create, then session/simulate-link (dynamicTransactions: true), then session/complete
  2. Enroll in Round-Up, tracking the linked institution account:
    POST /v1/roundup/create
  3. Inject transactions on the linked bank:
    POST /ztm/v1/linked-institution/{id}/simulate-transactions
  4. Verify the round-ups landed:
    GET /ztm/v1/linked-institution/{id}/transactions, where each row in data.rows carries a roundUp array
  5. Sweep on cadence, then review results via GET /v1/roundup/{id}/listSweepTransfers

Important: the institution must be linked with dynamicTransactions: true on session/simulate-link. Plaid only materializes injected transactions on its user_transactions_dynamic sandbox user; on the default sandbox test user, simulate-transactions succeeds but the transactions never appear.

The dynamic user is a package deal: along with injection support, the linked accounts arrive with a generated transaction history (typically 100+ rows) and keep producing new transactions on their own. Locate your injected rows by amount, not by description or row counts (see Step 4).


How Round-Up Works

A Round-Up enrollment ties together three account roles:

RoleWhat it doesAllowed types
TrackingTransactions here are watched and rounded upspidr, linked_institution_account
FundingThe swept money is pulled from herespidr, ach_account
ReceivingThe destination account the money lands inspidr

Each transaction on a tracking account is evaluated as it is ingested (for linked institutions, when the transaction syncs from the provider). If it is eligible, a roundUp entry is stamped on it. On the configured cadence, a sweep job aggregates the accrued round-ups, applies the caps, pulls the total from the funding account(s), and deposits it into the receiving account(s).

Calculation modes

calculationModeRound-up per eligible transactionRequires
round_to_nearest_dollarThe difference up to the next whole dollar (a $4.35 purchase gives $0.65)(none)
fixed_amount_per_transactionA flat fixedAmount per eligible transactionfixedAmount
round_to_nearest_dollar_plus_boostNearest-dollar round-up plus an extra boostAmountboostAmount

Cadence

cadencecadenceKey meaning
dailyRequired but unused, send any 1 to 28
weeklyDay of week, 1 to 7
biweeklyDay of week, 1 to 7
monthlyDay of month, 1 to 28

Eligibility filters (optional)

By default, every transaction on a tracking account is eligible. You can narrow that:

FieldApplies toNotes
eligibleCategoriesLinked institution tracking onlyArray of { type, value }, see category values. Rejected when tracking a Spidr account.
eligibleMccCodesAny trackingArray of MCC code strings (e.g. "5814" for fast food)

Credits, refunds, and cash-back are never rounded up. Only outflows (purchases) accrue round-ups.


Prerequisites

  • A sandbox environment. The simulation endpoints refuse to run in production or against a non-sandbox Plaid configuration.
  • Your API key (apikey header) for the sandbox environment.
  • A user with a Spidr funding account and a Spidr receiving (destination) account.
  • The receiving product must have Round-Up enabled (the roundup account feature). Without it, POST /v1/roundup/create returns ROUNDUP_NOT_ENABLED.
  • For linked institution tracking, the tracking product must be enrolled for Plaid transaction_aggregation. If only account_linking is enrolled, session/create with transaction_aggregation returns ZTM_PRODUCT_NOT_ENROLLED, and you cannot produce a linked_institution_account to track.

All example IDs below are placeholders. Substitute your own.


Step 1: Link an External Bank for Transaction Aggregation

Round-Up on a linked institution needs real transaction data flowing in, so link with the transaction_aggregation service.

Do the full sandbox link with three calls. This mirrors the Linked Institution Integration Guide, with one addition: dynamicTransactions: true.

1a. Create the session

API Reference: Create Linked Institution Session

POST /ztm/v1/linked-institution/session/create
Content-Type: application/json
apikey: <your-api-key>
x-client-request-id: <unique-request-id>

{
  "userId": "6984f595c07bda200b0e92d6",
  "productId": "6823a83e59a0f48f78787c29",
  "provider": "plaid",
  "services": ["account_linking", "transaction_aggregation"],
  "providerOptions": {
    "displayName": "My App"
  }
}

Save data.linkedInstitutionSessionId from the response.

1b. Simulate the Plaid Link flow

Instead of driving the Plaid UI, generate a sandbox publicToken directly. Set dynamicTransactions: true so the item can receive injected transactions.

API Reference: Simulate Plaid Link

POST /ztm/v1/linked-institution/session/simulate-link
Content-Type: application/json
apikey: <your-api-key>
x-client-request-id: <unique-request-id>

{
  "linkedInstitutionSessionId": "678abc123def456789012345",
  "institutionId": "ins_109508",
  "dynamicTransactions": true
}
FieldTypeRequiredDescription
linkedInstitutionSessionIdstringYesFrom session/create
institutionIdstringNoDefaults to ins_109508 (First Platypus Bank, the standard sandbox bank)
dynamicTransactionsbooleanNoSet true to inject transactions later. Links as Plaid's user_transactions_dynamic sandbox user, which also gives the accounts a self-generating transaction history (see Step 4). Defaults to false.

dynamicTransactions: true cuts both ways: the same sandbox user that accepts injected transactions also arrives with a canned transaction backlog and keeps generating new transactions on its own. Those organic transactions flow through the same ingestion pipeline, so once your enrollment is active they accrue round-ups too. There is no injection-only "quiet" mode. Pause or close test enrollments when you are done with them, or they keep accruing indefinitely.

Response:

{
  "status": "success",
  "data": {
    "publicToken": "public-sandbox-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
    "linkedInstitutionSessionId": "678abc123def456789012345"
  }
}

1c. Complete the session

API Reference: Complete Linked Institution Session

POST /ztm/v1/linked-institution/session/complete
Content-Type: application/json
apikey: <your-api-key>
x-client-request-id: <unique-request-id>

{
  "linkedInstitutionSessionId": "678abc123def456789012345",
  "providerOptions": {
    "publicToken": "public-sandbox-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
  }
}

From the response, save two IDs:

  • data.linkedInstitutions[0].id, the linkedInstitutionId (used for simulate-transactions and transactions)
  • data.linkedInstitutions[0].accounts[0].linkedInstitutionAccountId, the account you will track for round-ups

Step 2: Create a Round-Up Enrollment

Enroll the user, tracking the linked institution account captured in Step 1. Funding pulls from a Spidr account; the swept funds land in a Spidr receiving account.

API Reference: Create Round-Up Enrollment

POST /v1/roundup/create
Content-Type: application/json
apikey: <your-api-key>
x-client-request-id: <unique-request-id>

{
  "calculationMode": "round_to_nearest_dollar",
  "cadence": "weekly",
  "cadenceKey": 5,
  "cadenceMaxAmount": 50,
  "minimumSweepAmount": 1,
  "eligibleCategories": [
    { "type": "category", "value": "dining" },
    { "type": "category", "value": "transportation" }
  ],
  "trackingAccounts": [
    { "type": "linked_institution_account", "trackingAccountId": "6985060d649259b23141a602" }
  ],
  "fundingAccounts": [
    { "type": "spidr", "fundingAccountId": "6823a83e59a0f48f78787c30", "fundingPercentage": 100 }
  ],
  "receivingAccounts": [
    { "type": "spidr", "receivingAccountId": "6823a83e59a0f48f78787c31", "allocationPercentage": 100 }
  ]
}

Request Fields

FieldTypeRequiredDescription
calculationModestringYesround_to_nearest_dollar, fixed_amount_per_transaction, or round_to_nearest_dollar_plus_boost
fixedAmountnumberCond.Required when mode is fixed_amount_per_transaction; must be > 0
boostAmountnumberCond.Required when mode is round_to_nearest_dollar_plus_boost; must be > 0
currencyCodestringNo3-letter code, defaults to "USD"
cadencestringYesdaily, weekly, biweekly, or monthly
cadenceKeynumberYes1 to 28; must be 1 to 7 for weekly/biweekly (see Cadence)
cadenceMaxAmountnumberNoPer-period cap on total swept; must be > 0
minimumSweepAmountnumberNoFloor: do not sweep until accruals reach this; >= 0
achHoldDaysnumberNo0 to 89. Only valid when the receiving product has a round-up hold-days configuration (ROUNDUP_ACH_HOLD_DAYS_NOT_CONFIGURED otherwise); values below the product floor return ROUNDUP_ACH_HOLD_DAYS_INVALID
eligibleCategoriesarrayNoLinked-institution tracking only, see category values
eligibleMccCodesarrayNoMCC code strings; works with any tracking type
trackingAccountsarrayYesAt least one { type, trackingAccountId }
fundingAccountsarrayYesAt least one; fundingPercentage values must sum to 100
receivingAccountsarrayYesAt least one; allocationPercentage values must sum to 100

Tracking account: { type, trackingAccountId } where type is spidr or linked_institution_account.

Funding account: { type, fundingAccountId, fundingPercentage, cadenceMaxAmount?, minimumBalance? } where type is spidr or ach_account. fundingPercentage (> 0, <= 100) is required and all funding percentages must total 100.

Receiving account: { type, receivingAccountId, allocationPercentage } where type is spidr. allocationPercentage (> 0, <= 100) is required and all allocation percentages must total 100.

The enrollment userId is derived from the tracking/funding account owners, so you do not pass it. The receiving account must belong to that user unless the receiving product allows cross-user receiving; otherwise the call returns ROUNDUP_RECEIVING_ACCOUNT_USER_MISMATCH.

Response

{
  "status": "success",
  "data": {
    "id": "69860a1f649259b23141a7c4",
    "companyId": "698609f0649259b23141a7b0",
    "userId": "6984f595c07bda200b0e92d6",
    "status": "active",
    "calculationMode": "round_to_nearest_dollar",
    "currencyCode": "USD",
    "eligibleCategories": [
      { "type": "category", "value": "dining" },
      { "type": "category", "value": "transportation" }
    ],
    "cadence": "weekly",
    "cadenceKey": 5,
    "cadenceMaxAmount": 50,
    "minimumSweepAmount": 1,
    "trackingAccounts": [
      { "type": "linked_institution_account", "trackingAccountId": "6985060d649259b23141a602" }
    ],
    "fundingAccounts": [
      { "type": "spidr", "fundingAccountId": "6823a83e59a0f48f78787c30", "fundingPercentage": 100 }
    ],
    "receivingAccounts": [
      { "type": "spidr", "receivingAccountId": "6823a83e59a0f48f78787c31", "allocationPercentage": 100 }
    ],
    "createdAt": "2026-07-06T14:02:11.000Z",
    "updatedAt": "2026-07-06T14:02:11.000Z",
    "spidrActionId": "69860a1f649259b23141a7c6"
  }
}

Save data.id, the enrollment ID, used to inspect sweeps. Enrollment write responses (create, update, updateStatus) also carry a spidrActionId for audit correlation.

Funding from a separate linked bank (ACH)

Funding accepts only spidr or ach_account, never linked_institution_account. To fund a sweep from an external bank (optionally a different bank than the one you track), link that bank with the account_linking service and register its account as an ach_account, then reference that ACH account as funding.

  1. Link bank B with the same three-call flow as Step 1, but services: ["account_linking"] is enough (transaction_aggregation is not needed for funding).
  2. Create an ACH account from bank B's linked account. POST /v1/achaccount/create accepts a linkedInstitutionAccountId and resolves the routing/account numbers for you:
POST /v1/achaccount/create
apikey: <your-api-key>
x-client-request-id: <unique-request-id>

{
  "accountId": "<a spidr account owned by the user>",
  "linkedInstitutionAccountId": "<bank B depository account id>",
  "name": "Jane Doe",
  "firstName": "Jane",
  "lastName": "Doe",
  "type": "checking"
}

Save data.achAccountId.
3. In the enrollment, set funding to that ACH account:

"fundingAccounts": [
  { "type": "ach_account", "fundingAccountId": "<achAccountId>", "fundingPercentage": 100 }
]

This yields the common configuration of tracking one external bank while funding from another: a linked_institution_account for tracking, and an ach_account sourced from the second bank for funding. Both are external banks; only the funding side runs through an ACH account.


Step 3: Simulate Transactions

This is the core of sandbox testing. Inject synthetic transactions onto the linked bank; Spidr runs them through the real ingestion pipeline exactly like organic transactions.

API Reference: Simulate Transactions

POST /ztm/v1/linked-institution/6985060d649259b23141a601/simulate-transactions
Content-Type: application/json
apikey: <your-api-key>
x-client-request-id: <unique-request-id>

{
  "transactions": [
    { "description": "Starbucks",       "amount": 4.35 },
    { "description": "Uber",            "amount": 18.72 },
    { "description": "Shell Oil",       "amount": 41.10 },
    { "description": "Refund - Amazon", "amount": -12.00 }
  ],
  "triggerSync": true
}

Or with curl:

curl -X POST "https://api.gospidr.com/ztm/v1/linked-institution/6985060d649259b23141a601/simulate-transactions" \
  -H "Content-Type: application/json" \
  -H "apikey: $SPIDR_API_KEY" \
  -H "x-client-request-id: $(uuidgen)" \
  -d '{
    "transactions": [
      { "description": "Starbucks", "amount": 4.35 },
      { "description": "Uber", "amount": 18.72 }
    ]
  }'

Request Fields

FieldTypeRequiredDescription
linkedInstitutionIdstringYesPath parameter, the linked institution to inject into
transactionsarrayYes1 to 10 transactions (Plaid caps sandbox creation at 10 per call)
transactions[].descriptionstringYesMerchant-style text (1 to 200 chars). Plaid derives the category from this, see below
transactions[].amountnumberYesPositive = outflow/purchase (rounds up), negative = inflow/refund (not rounded)
transactions[].dateTransactedstringNoAuthorization date YYYY-MM-DD. Defaults to today. Today or up to 14 days in the past, never future
transactions[].datePostedstringNoSettlement date YYYY-MM-DD. Defaults to dateTransacted. Same 14-day-past constraint
transactions[].isoCurrencyCodestringNo3-letter code, defaults to "USD"
triggerSyncbooleanNoWhen true (default), enqueues a sync so transactions flow in immediately instead of waiting for Plaid's webhook

Controlling the category

Use merchant-like descriptions to steer categorization:

DescriptionTypically maps to
StarbucksFood & Dining / Coffee Shops
UberTransportation / Rideshare
Shell OilTransportation / Gas & Fuel
Netflix.comEntertainment

Category assignment is ultimately determined by Plaid. When testing eligibleCategories, assert on the mapped Spidr category after sync rather than assuming a specific one up front.

Plaid also normalizes the merchant name it stores: Shell Oil comes back as Shell, and Refund - Amazon comes back as Amazon. Do not expect your exact description text in the synced rows; match injected rows by amount.

Response

{
  "status": "success",
  "data": {
    "linkedInstitutionId": "6985060d649259b23141a601",
    "transactionsCreated": 4,
    "syncEnqueued": true
  }
}

The Plaid injection is all-or-nothing: transactionsCreated reflects the whole batch.

Timing matters. Give the link a moment to settle before injecting: a batch created within the first seconds after session/complete can be silently dropped by Plaid. The call still returns the full transactionsCreated count with a 200, but the rows never materialize, and no later sync recovers that batch. If nothing has appeared after ~30 seconds of polling in Step 4, re-inject the batch (a fresh simulate-transactions call also re-enqueues the sync).


Step 4: Verify the Round-Ups

Once the sync completes, fetch the linked institution's transactions. Each eligible transaction carries a roundUp array.

API Reference: Fetch Linked Institution Transactions

Note: the transactions are returned under data.rows, not data.transactions. Each row carries a roundUp array when it accrued. A round-up of 0 means the amount was already a whole dollar. Injected transactions can take a few seconds to sync; poll this endpoint until they appear. Two things to expect while polling:

  • The list is not just your injections. The dynamic sandbox user arrives with its own transaction history (typically 100+ rows) and keeps generating more, so find your injected rows by amount: descriptions are normalized by Plaid, and row counts keep moving.
  • Accrual can trail sync by a few seconds. A row can appear before its roundUp entry is stamped. Keep polling until the round-ups are present, not just the rows.
GET /ztm/v1/linked-institution/6985060d649259b23141a601/transactions?current=1&pageSize=50
apikey: <your-api-key>
x-client-request-id: <unique-request-id>

Query Parameters

ParameterTypeRequiredDescription
linkedInstitutionAccountIdsarrayNoRestrict to specific linked accounts
startDate / endDatestringNoYYYY-MM-DD range. Defaults to the last 30 days when omitted
currentnumberNoPage number, defaults to 1
pageSizenumberNoRows per page, defaults to 50 (max 500)

Results are returned most recent first, with totalPages and totalRecords alongside rows.

Response (abridged rows)

{
  "status": "success",
  "data": {
    "rows": [
      {
        "transactionId": "6986113a649259b23141a812",
        "description": "Starbucks",
        "merchantDescription": "Starbucks",
        "amount": 4.35,
        "currencyCode": "USD",
        "status": "settled",
        "authDate": "2026-07-06",
        "postedDate": "2026-07-06",
        "category": "dining",
        "subCategory": "dining.coffee_shops",
        "linkedInstitutionAccountId": "6985060d649259b23141a602",
        "roundUp": [{ "amount": 0.65, "status": "pending" }]
      },
      {
        "description": "Uber",
        "amount": 18.72,
        "category": "transportation",
        "subCategory": "transportation.rideshare_and_taxi",
        "roundUp": [{ "amount": 0.28, "status": "pending" }]
      },
      {
        "description": "Shell",
        "amount": 41.10,
        "category": "transportation",
        "subCategory": "transportation.gas_and_fuel",
        "roundUp": [{ "amount": 0.90, "status": "pending" }]
      },
      {
        "description": "Amazon",
        "amount": -12.00,
        "category": "shopping"
      },
      {
        "description": "Madison Bicycle Shop",
        "amount": 78.23,
        "category": "shopping"
      }
    ],
    "totalPages": 3,
    "totalRecords": 122
  }
}

Note:

  • $4.35 gives $0.65, $18.72 gives $0.28, and $41.10 gives $0.90, rounded up to the next dollar.
  • Plaid normalized the injected descriptions: Shell Oil is stored as Shell, and Refund - Amazon as Amazon. This is why you match injected rows by amount.
  • The refund is a credit (negative amount), so it has no roundUp.
  • Madison Bicycle Shop is one of the dynamic user's own generated transactions (hence totalRecords: 122). It has no roundUp because it synced before the enrollment existed; organic rows that sync after the enrollment is active accrue like any other transaction, subject to your eligibility filters.
  • roundUp is an array: a transaction matched by multiple enrollments gets one entry per enrollment.

Round-up entry statuses

roundUp[].statusMeaning
pendingAccrued, waiting to be picked up by the next sweep
sweptIncluded in a completed sweep
cappedExcluded because the period cap (cadenceMaxAmount) was already reached
skippedExcluded from the sweep for another reason

Step 5: Sweep and Reconcile

Round-ups accumulate with status: "pending" until the sweep runs on your configured cadence. The sweep aggregates the period's round-ups (respecting cadenceMaxAmount and minimumSweepAmount), pulls the total from the funding account(s), and deposits it into the receiving account(s).

API Reference: List Round-Up Sweep Transfers
Review sweep results per enrollment:

GET /v1/roundup/69860a1f649259b23141a7c4/listSweepTransfers?current=1&pageSize=25
apikey: <your-api-key>
x-client-request-id: <unique-request-id>

Supports current (default 1) and pageSize (default 25, max 100) pagination, plus optional JSON-encoded filters and sort query parameters over status, currencyCode, sweepKey, period.startDate, period.endDate, createdAt, and updatedAt.

Each record shows how a cadence period's round-up total was calculated and the resulting funding and receiving transfers:

{
  "status": "success",
  "data": {
    "totalRecords": 1,
    "rows": [
      {
        "sweepId": "69875c02649259b23141a901",
        "sweepKey": "<period-key>",
        "status": "success",
        "currencyCode": "USD",
        "period": {
          "cadence": "weekly",
          "startDate": "2026-06-29T00:00:00.000Z",
          "endDate": "2026-07-05T23:59:59.999Z"
        },
        "calculation": {
          "eligibleTransactionCount": 3,
          "eligibleTransactionAmount": 64.17,
          "calculatedRoundUpAmount": 1.83,
          "cadenceMaxAmount": 50,
          "finalAmount": 1.83,
          "calculationMode": "round_to_nearest_dollar"
        },
        "fundingAccountTransfers": [
          {
            "type": "spidr",
            "fundingAccountId": "6823a83e59a0f48f78787c30",
            "amount": 1.83,
            "fundingPercentage": 100,
            "status": "success",
            "submittedAt": "2026-07-06T05:00:12.000Z",
            "completedAt": "2026-07-06T05:00:14.000Z"
          }
        ],
        "receivingAccountTransfers": [
          {
            "type": "spidr",
            "receivingAccountId": "6823a83e59a0f48f78787c31",
            "amount": 1.83,
            "allocationPercentage": 100,
            "status": "success"
          }
        ]
      }
    ]
  }
}

Sweep and transfer statuses

Sweep statusMeaning
pendingSweep created, transfers not yet submitted
funding_submitted / funding_completeFunding pull in flight / landed
receiving_transfers_submittedDeposit(s) to receiving account(s) in flight
success / partial_success / failedTerminal outcomes
skippedNothing moved this period; see skipReason
skipReasonMeaning
enrollment_pausedEnrollment was paused during the sweep window
insufficient_fundsFunding account could not cover the sweep amount
below_minimum_sweep_amountAccrued total was under minimumSweepAmount
final_amount_not_positiveCaps or refunds reduced the sweep to 0 or below
unsupported_transfer_configurationAccount combination could not be transferred

Individual funding/receiving transfers carry their own status (pending, submitted, success, failed, skipped) and an optional failureReason.

Sweeps run automatically on the configured cadence. There is currently no on-demand sweep trigger, so to observe a sweep during testing, use a cadence that fires within your test window (daily is the fastest: the enrollment is picked up by the next daily sweep run).


Managing the Enrollment

Beyond create, the enrollment has a full lifecycle API:

Pause, resume, or close with POST /v1/roundup/{id}/updateStatus. While an enrollment is paused, its sweeps are skipped (skipReason: "enrollment_paused"); closed ends the enrollment. The enrollment records pausedAt / resumedAt / closedAt timestamps.

POST /v1/roundup/69860a1f649259b23141a7c4/updateStatus
Content-Type: application/json
apikey: <your-api-key>
x-client-request-id: <unique-request-id>

{ "status": "paused" }
FieldTypeRequiredDescription
statusstringYesactive, paused, or closed

Reconfigure with PATCH /v1/roundup/{id}. Any subset of the create fields may be supplied; omitted fields are left unchanged. Use updateStatus (not PATCH) to change the lifecycle state.

Read with GET /v1/roundup/{id} for a single enrollment, or GET /v1/roundup/list for a paginated, filterable list of your enrollments.


Reference

Category values

eligibleCategories entries are { type, value }:

  • type: "category": a top-level category.
  • type: "subCategory": a more specific sub-category.

Category values (lowercase snake_case):

dining, groceries, shopping, transportation, travel, utilities, home, health_and_wellness, personal_care, entertainment, family_and_pets, education, professional_services, insurance, charity_and_giving, taxes_and_government, cash_out, income, transfers, loan_payments, fees_and_adjustments, uncategorized

Sub-category values use dot notation, <category>.<sub_category>, for example dining.coffee_shops, dining.fast_food, transportation.gas_and_fuel, transportation.rideshare_and_taxi, entertainment.cable_and_streaming. Transactions carry the same values in their category / subCategory fields, so read a few synced transactions to see which values your test data maps to.

Endpoint quick reference

EndpointMethodPurpose
/ztm/v1/linked-institution/session/createPOSTStart a link session
/ztm/v1/linked-institution/session/simulate-linkPOSTSandbox: generate a publicToken (set dynamicTransactions: true)
/ztm/v1/linked-institution/session/completePOSTFinish linking, get linkedInstitutionId
/ztm/v1/linked-institution/{id}/simulate-transactionsPOSTSandbox: inject synthetic transactions
/ztm/v1/linked-institution/{id}/transactionsGETFetch transactions with roundUp entries
/ztm/v1/linked-institution/{id}/refreshPOSTRefresh institution data from Plaid
/v1/roundup/createPOSTCreate a Round-Up enrollment
/v1/roundup/listGETList enrollments
/v1/roundup/{id}GETGet an enrollment
/v1/roundup/{id}PATCHUpdate an enrollment's configuration
/v1/roundup/{id}/updateStatusPOSTPause, resume, or close an enrollment
/v1/roundup/{id}/listSweepTransfersGETList sweep transfer records

Troubleshooting

Symptom / ErrorCauseFix
Injected transactions never appearInstitution was linked on the default sandbox test userRe-link with dynamicTransactions: true on session/simulate-link
Injected transactions never appear, even with dynamicTransactions: trueBatch was injected too soon after linking and Plaid silently dropped it (transactionsCreated still returned the full count)Re-inject the batch (a fresh simulate-transactions call re-enqueues the sync); leave a short pause between session/complete and the first injection
Row synced but its roundUp is missingAccrual stamps a few seconds after the row syncsKeep polling briefly; investigate category/MCC filters only if it never appears
Transactions you never injected show upThe user_transactions_dynamic sandbox user generates its own transaction history and keeps adding to itExpected; match your injected rows by amount
No transactions sync at allInstitution linked without transaction_aggregationInclude transaction_aggregation (and account_linking) in services
OPERATION_NOT_ALLOWED_IN_PRODUCTIONSimulation endpoints called in productionUse a sandbox environment
OPERATION_REQUIRES_SANDBOX_ENVIRONMENTEnvironment is not pointed at Plaid sandboxUse a sandbox-configured environment
ROUNDUP_NOT_ENABLEDReceiving product lacks the roundup account featureEnable Round-Up on the receiving product
ZTM_PRODUCT_NOT_ENROLLED on session/createProduct not enrolled for the requested Plaid serviceEnroll transaction_aggregation (for tracking) or account_linking (for funding) on the product's vendorServices
ROUNDUP_ACH_HOLD_DAYS_NOT_ELIGIBLEReceiving product has a round-up hold-days floor but its standard ACH request is not hold-days eligibleSet holdDaysEligible: true on the standard (non-same-day) ACH request, or remove roundup.minimumAchHoldDays
ROUNDUP_ACH_HOLD_DAYS_NOT_CONFIGUREDachHoldDays passed but the receiving product has no round-up hold-days configurationOmit achHoldDays, or configure round-up hold days on the product
Sweep record has status: "skipped"See the record's skipReason (insufficient_funds, below_minimum_sweep_amount, enrollment_paused, ...)Address the cause (fund the funding account, lower minimumSweepAmount, resume the enrollment) before the next cadence window
GET .../transactions returns no rowsReading the wrong field, or sync has not completedTransactions are under data.rows (not data.transactions); poll for a few seconds after simulate-transactions
ROUNDUP_RECEIVING_ACCOUNT_USER_MISMATCHReceiving account belongs to a different userUse the enrollment user's account, or enable cross-user receiving
ROUNDUP_PERCENTAGE_TOTAL_INVALIDfundingPercentage or allocationPercentage doesn't sum to 100Make each set of percentages total 100
A transaction didn't round upIt was a credit/refund/cash-back, or excluded by category/MCC filtersOnly positive-amount purchases matching your filters accrue round-ups
Date rejected by PlaiddateTransacted/datePosted was in the future or over 14 days pastUse today or up to 14 days in the past

Additional Resources