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
- Link an external bank for transaction aggregation:
session/create, thensession/simulate-link(dynamicTransactions: true), thensession/complete - Enroll in Round-Up, tracking the linked institution account:
POST /v1/roundup/create - Inject transactions on the linked bank:
POST /ztm/v1/linked-institution/{id}/simulate-transactions - Verify the round-ups landed:
GET /ztm/v1/linked-institution/{id}/transactions, where each row indata.rowscarries aroundUparray - Sweep on cadence, then review results via
GET /v1/roundup/{id}/listSweepTransfers
Important: the institution must be linked with
dynamicTransactions: trueonsession/simulate-link. Plaid only materializes injected transactions on itsuser_transactions_dynamicsandbox user; on the default sandbox test user,simulate-transactionssucceeds 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:
| Role | What it does | Allowed types |
|---|---|---|
| Tracking | Transactions here are watched and rounded up | spidr, linked_institution_account |
| Funding | The swept money is pulled from here | spidr, ach_account |
| Receiving | The destination account the money lands in | spidr |
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
calculationMode | Round-up per eligible transaction | Requires |
|---|---|---|
round_to_nearest_dollar | The difference up to the next whole dollar (a $4.35 purchase gives $0.65) | (none) |
fixed_amount_per_transaction | A flat fixedAmount per eligible transaction | fixedAmount |
round_to_nearest_dollar_plus_boost | Nearest-dollar round-up plus an extra boostAmount | boostAmount |
Cadence
cadence | cadenceKey meaning |
|---|---|
daily | Required but unused, send any 1 to 28 |
weekly | Day of week, 1 to 7 |
biweekly | Day of week, 1 to 7 |
monthly | Day of month, 1 to 28 |
Eligibility filters (optional)
By default, every transaction on a tracking account is eligible. You can narrow that:
| Field | Applies to | Notes |
|---|---|---|
eligibleCategories | Linked institution tracking only | Array of { type, value }, see category values. Rejected when tracking a Spidr account. |
eligibleMccCodes | Any tracking | Array 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 (
apikeyheader) 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
roundupaccount feature). Without it,POST /v1/roundup/createreturnsROUNDUP_NOT_ENABLED. - For linked institution tracking, the tracking product must be enrolled for Plaid
transaction_aggregation. If onlyaccount_linkingis enrolled,session/createwithtransaction_aggregationreturnsZTM_PRODUCT_NOT_ENROLLED, and you cannot produce alinked_institution_accountto 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
}| Field | Type | Required | Description |
|---|---|---|---|
linkedInstitutionSessionId | string | Yes | From session/create |
institutionId | string | No | Defaults to ins_109508 (First Platypus Bank, the standard sandbox bank) |
dynamicTransactions | boolean | No | Set 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: truecuts 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, thelinkedInstitutionId(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
| Field | Type | Required | Description |
|---|---|---|---|
calculationMode | string | Yes | round_to_nearest_dollar, fixed_amount_per_transaction, or round_to_nearest_dollar_plus_boost |
fixedAmount | number | Cond. | Required when mode is fixed_amount_per_transaction; must be > 0 |
boostAmount | number | Cond. | Required when mode is round_to_nearest_dollar_plus_boost; must be > 0 |
currencyCode | string | No | 3-letter code, defaults to "USD" |
cadence | string | Yes | daily, weekly, biweekly, or monthly |
cadenceKey | number | Yes | 1 to 28; must be 1 to 7 for weekly/biweekly (see Cadence) |
cadenceMaxAmount | number | No | Per-period cap on total swept; must be > 0 |
minimumSweepAmount | number | No | Floor: do not sweep until accruals reach this; >= 0 |
achHoldDays | number | No | 0 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 |
eligibleCategories | array | No | Linked-institution tracking only, see category values |
eligibleMccCodes | array | No | MCC code strings; works with any tracking type |
trackingAccounts | array | Yes | At least one { type, trackingAccountId } |
fundingAccounts | array | Yes | At least one; fundingPercentage values must sum to 100 |
receivingAccounts | array | Yes | At 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
userIdis 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 returnsROUNDUP_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.
- Link bank B with the same three-call flow as Step 1, but
services: ["account_linking"]is enough (transaction_aggregationis not needed for funding). - Create an ACH account from bank B's linked account.
POST /v1/achaccount/createaccepts alinkedInstitutionAccountIdand 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
| Field | Type | Required | Description |
|---|---|---|---|
linkedInstitutionId | string | Yes | Path parameter, the linked institution to inject into |
transactions | array | Yes | 1 to 10 transactions (Plaid caps sandbox creation at 10 per call) |
transactions[].description | string | Yes | Merchant-style text (1 to 200 chars). Plaid derives the category from this, see below |
transactions[].amount | number | Yes | Positive = outflow/purchase (rounds up), negative = inflow/refund (not rounded) |
transactions[].dateTransacted | string | No | Authorization date YYYY-MM-DD. Defaults to today. Today or up to 14 days in the past, never future |
transactions[].datePosted | string | No | Settlement date YYYY-MM-DD. Defaults to dateTransacted. Same 14-day-past constraint |
transactions[].isoCurrencyCode | string | No | 3-letter code, defaults to "USD" |
triggerSync | boolean | No | When 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:
| Description | Typically maps to |
|---|---|
Starbucks | Food & Dining / Coffee Shops |
Uber | Transportation / Rideshare |
Shell Oil | Transportation / Gas & Fuel |
Netflix.com | Entertainment |
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 Oilcomes back asShell, andRefund - Amazoncomes back asAmazon. Do not expect your exactdescriptiontext 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/completecan be silently dropped by Plaid. The call still returns the fulltransactionsCreatedcount with a200, 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 freshsimulate-transactionscall 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, notdata.transactions. Each row carries aroundUparray when it accrued. A round-up of0means 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
roundUpentry 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
| Parameter | Type | Required | Description |
|---|---|---|---|
linkedInstitutionAccountIds | array | No | Restrict to specific linked accounts |
startDate / endDate | string | No | YYYY-MM-DD range. Defaults to the last 30 days when omitted |
current | number | No | Page number, defaults to 1 |
pageSize | number | No | Rows 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.35gives$0.65,$18.72gives$0.28, and$41.10gives$0.90, rounded up to the next dollar.- Plaid normalized the injected descriptions:
Shell Oilis stored asShell, andRefund - AmazonasAmazon. This is why you match injected rows by amount. - The refund is a credit (negative amount), so it has no
roundUp. Madison Bicycle Shopis one of the dynamic user's own generated transactions (hencetotalRecords: 122). It has noroundUpbecause 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.roundUpis an array: a transaction matched by multiple enrollments gets one entry per enrollment.
Round-up entry statuses
roundUp[].status | Meaning |
|---|---|
pending | Accrued, waiting to be picked up by the next sweep |
swept | Included in a completed sweep |
capped | Excluded because the period cap (cadenceMaxAmount) was already reached |
skipped | Excluded 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 status | Meaning |
|---|---|
pending | Sweep created, transfers not yet submitted |
funding_submitted / funding_complete | Funding pull in flight / landed |
receiving_transfers_submitted | Deposit(s) to receiving account(s) in flight |
success / partial_success / failed | Terminal outcomes |
skipped | Nothing moved this period; see skipReason |
skipReason | Meaning |
|---|---|
enrollment_paused | Enrollment was paused during the sweep window |
insufficient_funds | Funding account could not cover the sweep amount |
below_minimum_sweep_amount | Accrued total was under minimumSweepAmount |
final_amount_not_positive | Caps or refunds reduced the sweep to 0 or below |
unsupported_transfer_configuration | Account 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 (
dailyis 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" }| Field | Type | Required | Description |
|---|---|---|---|
status | string | Yes | active, 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
| Endpoint | Method | Purpose |
|---|---|---|
/ztm/v1/linked-institution/session/create | POST | Start a link session |
/ztm/v1/linked-institution/session/simulate-link | POST | Sandbox: generate a publicToken (set dynamicTransactions: true) |
/ztm/v1/linked-institution/session/complete | POST | Finish linking, get linkedInstitutionId |
/ztm/v1/linked-institution/{id}/simulate-transactions | POST | Sandbox: inject synthetic transactions |
/ztm/v1/linked-institution/{id}/transactions | GET | Fetch transactions with roundUp entries |
/ztm/v1/linked-institution/{id}/refresh | POST | Refresh institution data from Plaid |
/v1/roundup/create | POST | Create a Round-Up enrollment |
/v1/roundup/list | GET | List enrollments |
/v1/roundup/{id} | GET | Get an enrollment |
/v1/roundup/{id} | PATCH | Update an enrollment's configuration |
/v1/roundup/{id}/updateStatus | POST | Pause, resume, or close an enrollment |
/v1/roundup/{id}/listSweepTransfers | GET | List sweep transfer records |
Troubleshooting
| Symptom / Error | Cause | Fix |
|---|---|---|
| Injected transactions never appear | Institution was linked on the default sandbox test user | Re-link with dynamicTransactions: true on session/simulate-link |
Injected transactions never appear, even with dynamicTransactions: true | Batch 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 missing | Accrual stamps a few seconds after the row syncs | Keep polling briefly; investigate category/MCC filters only if it never appears |
| Transactions you never injected show up | The user_transactions_dynamic sandbox user generates its own transaction history and keeps adding to it | Expected; match your injected rows by amount |
| No transactions sync at all | Institution linked without transaction_aggregation | Include transaction_aggregation (and account_linking) in services |
OPERATION_NOT_ALLOWED_IN_PRODUCTION | Simulation endpoints called in production | Use a sandbox environment |
OPERATION_REQUIRES_SANDBOX_ENVIRONMENT | Environment is not pointed at Plaid sandbox | Use a sandbox-configured environment |
ROUNDUP_NOT_ENABLED | Receiving product lacks the roundup account feature | Enable Round-Up on the receiving product |
ZTM_PRODUCT_NOT_ENROLLED on session/create | Product not enrolled for the requested Plaid service | Enroll transaction_aggregation (for tracking) or account_linking (for funding) on the product's vendorServices |
ROUNDUP_ACH_HOLD_DAYS_NOT_ELIGIBLE | Receiving product has a round-up hold-days floor but its standard ACH request is not hold-days eligible | Set holdDaysEligible: true on the standard (non-same-day) ACH request, or remove roundup.minimumAchHoldDays |
ROUNDUP_ACH_HOLD_DAYS_NOT_CONFIGURED | achHoldDays passed but the receiving product has no round-up hold-days configuration | Omit 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 rows | Reading the wrong field, or sync has not completed | Transactions are under data.rows (not data.transactions); poll for a few seconds after simulate-transactions |
ROUNDUP_RECEIVING_ACCOUNT_USER_MISMATCH | Receiving account belongs to a different user | Use the enrollment user's account, or enable cross-user receiving |
ROUNDUP_PERCENTAGE_TOTAL_INVALID | fundingPercentage or allocationPercentage doesn't sum to 100 | Make each set of percentages total 100 |
| A transaction didn't round up | It was a credit/refund/cash-back, or excluded by category/MCC filters | Only positive-amount purchases matching your filters accrue round-ups |
| Date rejected by Plaid | dateTransacted/datePosted was in the future or over 14 days past | Use today or up to 14 days in the past |