Testing Bank Linking
Introduction
This guide walks bank linking end to end in the sandbox environment: create a link, complete it against a provider test bank, read the created resources, then watch bank activity arrive on the unified transaction feed. A bank posting that matches a pending card authorization settles it early with a transaction.updated, and a bank posting with no card counterpart arrives as its own transaction.created, on the same webhooks you already handle for card spend. The bank-specific webhooks (banktransaction.created, transaction.match.created) fire alongside for reconciliation. Every request runs against https://api.sandbox.astrada.co with a bearer token from Get a token. The flow is identical to production; only the bank behind the hosted window is fake.
You can complete the link two ways: open the hostedLink from the API response directly (steps 3 and beyond), or embed the Unified Enrollment SDK from the sandbox host (step 4). Both end in the same webhooks and resources.
1. Create a subaccount and register a webhook
Create a fresh subaccount for the test run, so cleanup is trivial and events are easy to attribute:
POST /subaccounts HTTP/1.1
Authorization: Bearer YOUR_ACCESS_TOKEN
Content-Type: application/json
{
"name": "bank-linking-test",
"configurations": {
"VISA": { "countries": "*" },
"MASTERCARD": { "countries": "*" }
}
}curl -X POST https://api.sandbox.astrada.co/subaccounts \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "bank-linking-test",
"configurations": {
"VISA": { "countries": "*" },
"MASTERCARD": { "countries": "*" }
}
}'{
"name": "bank-linking-test",
"configurations": {
"VISA": { "countries": "*" },
"MASTERCARD": { "countries": "*" }
}
}Response (201 Created): the subaccount; keep its id as {subaccountId} for everything below.
Then register a webhook. The first three event types are the unified feed; the rest are the bank-linking lifecycle and the reconciliation surface. A webhook.site URL works well as a throwaway receiver:
curl -X POST https://api.sandbox.astrada.co/webhooks \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"subaccountId": "{subaccountId}",
"url": "https://webhook.site/your-unique-url",
"description": "bank linking test",
"eventTypes": [
"transaction.created",
"transaction.updated",
"transactionmessage.created",
"banklink.completed",
"bankaccount.state_changed",
"cardsubscription.created",
"banktransaction.created",
"transaction.match.created"
]
}'Response (201 Created): the webhook with its signing secret. transactionmessage.created is optional; it is the one payload that carries the bankData provenance block, which is why this walkthrough subscribes to it. What each event means is covered in Bank Linking Webhooks & Events.
2. Choose the bank rail
Bank links are created against a provider, resolved from provider and countryCode: plaid connects US and Canadian banks (USA, CAN), and mastercard is Mastercard Open Finance, which connects US banks (USA, through Finicity) and EU banks (DEU, FRA, NLD, ESP, BEL, PRT). You can pass the provider per link in step 3, or a subaccount default can be set that the SDK path also uses. Setting the default requires the accounts:write scope, which is held by Astrada-managed admin clients rather than integration clients, so ask Astrada to configure it; in this walkthrough, pass the provider per link.
curl -X PATCH "https://api.sandbox.astrada.co/subaccounts/{subaccountId}/bank-linking-policy" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"defaultProvider": "plaid",
"defaultCountryCode": "USA",
"accountTypes": ["credit"]
}'Response (200 OK): the effective policy. accountTypes: ["credit"] keeps the hosted window focused on credit-card accounts, which is also what card minting works from; the system default is credit-only.
3. Create a bank link with the API
POST /bank-links HTTP/1.1
Authorization: Bearer YOUR_ACCESS_TOKEN
Content-Type: application/json
{
"id": "sandbox-walkthrough-1",
"subaccountId": "{subaccountId}",
"provider": "plaid",
"countryCode": "USA"
}curl -X POST https://api.sandbox.astrada.co/bank-links \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "sandbox-walkthrough-1",
"subaccountId": "{subaccountId}",
"provider": "plaid",
"countryCode": "USA"
}'{
"id": "sandbox-walkthrough-1",
"subaccountId": "{subaccountId}",
"provider": "plaid",
"countryCode": "USA"
}Response (201 Created):
{
"id": "sandbox-walkthrough-1",
"subaccountId": "...",
"state": "pending",
"hostedLink": "https://secure.plaid.com/hl/...",
"linkExpiresAt": "2026-09-02T11:20:41Z",
"createdAt": "2026-09-02T07:20:41Z"
}Open the hostedLink in a browser and complete it against the provider's test bank.
Plaid ("provider": "plaid"): in the hosted window, search for First Platypus Bank. Sign in with username user_custom and, as the password, a JSON object describing the accounts you want. This one creates a credit-card account with two postings dated yesterday (put yesterday's date in both date fields): a 200.00 hotel charge that step 7 will match against a card authorization, and a 12.75 parking fee with no card counterpart:
{
"override_accounts": [
{
"type": "credit",
"subtype": "credit card",
"mask": "4545",
"starting_balance": 500,
"transactions": [
{
"date_transacted": "2026-09-01",
"date_posted": "2026-09-01",
"amount": 200.00,
"description": "MARRIOTT",
"currency": "USD"
},
{
"date_transacted": "2026-09-01",
"date_posted": "2026-09-01",
"amount": 12.75,
"description": "CITY PARKING GARAGE",
"currency": "USD"
}
]
}
]
}Yesterday matters: the sync that runs while the link completes skips postings dated before the link was created, so these two stay on the provider side until step 7 pulls them in. By then the account's bank-feed card exists, and both postings attribute to it.
Plaid's simpler fixed fixtures (user_good / pass_good) also work when you do not need specific accounts or transactions.
Mastercard US ("provider": "mastercard", "countryCode": "USA"): the hosted window is Finicity Connect. Search for FinBank Profiles - A and sign in with profile_02 / profile_02 for a profile that includes a credit card (mask 3333), or profile_03 / profile_03 for a depository-only profile. Select all accounts and save.
Mastercard EU ("provider": "mastercard", "countryCode": "DEU"): the hosted window is Mastercard Open Finance (the MTF test environment). On the "Choose your bank" screen, pick Test Banks, then Mock Bank. Sign in with User ID john.smith, choose Success on the authentication screen, and Allow and share data on the consent screen. Supported EU country codes include DEU, FRA, NLD, ESP, BEL, and PRT.
4. Create a bank link with the SDK
The Unified Enrollment SDK runs the same connect step embedded in your page. In sandbox, load the bundle from the sandbox host; it auto-detects the environment and calls the sandbox API:
<script src="https://sdk.sandbox.astrada.co/unified/v1/unifiedEnrollmentSdk.js"></script>
<script>
UnifiedEnrollmentSdk.open({
subaccountId: "{subaccountId}",
companyName: "Your company",
getAccessToken: () => fetch("/your-backend/astrada-sandbox-token").then((r) => r.text()),
onEvent: (e) => console.log(e),
});
</script>Enable the bank rail on the subaccount's enrollmentPolicy (or open with a bank entry) as described in Choose your rails, then complete the provider window with the same test-bank fixtures as step 3. You get a bank-linked event with the connection and its accounts, plus one card-enrolled event per minted card with enrollmentType: "bank-feed".
Mint the token on your backend
getAccessTokenmust return a token minted server-side with your client credentials, exactly as in production. Minting from the browser fails with403 {"error":"Invalid origin"}unless the calling origin is registered on your OAuth client.
5. See what was created
Completion is asynchronous but fast; poll the link until state is completed (the banklink.completed webhook carries the same account list):
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
"https://api.sandbox.astrada.co/bank-links/sandbox-walkthrough-1"Response (200 OK): "state": "completed".
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
"https://api.sandbox.astrada.co/bank-accounts?subaccountId={subaccountId}"Response (200 OK), trimmed: the discovered accounts. Credit-card accounts arrive with a minted bank-feed card already linked. Keep the account's id as {bankAccountId} and the card's as {cardId}:
{
"items": [
{
"id": "4f4ca7ed-919e-47f9-8b27-7b685d3d92a5",
"mask": "4545",
"type": "credit",
"institutionName": "First Platypus Bank",
"state": "active",
"cardIds": ["b1daccbe-65b5-4a93-a174-95bbbe21500a"]
}
]
}The minted card is a first-class card: GET /card-subscriptions?cardId={cardId} shows its subscription with enrollmentType: "bank-feed" and a bankAccountId back-reference, and a cardsubscription.created webhook fired for it. See Unified Card & Bank Feeds for the model. Every bank posting on this account will now surface on the unified feed as a transaction with this cardId.
At this point GET /bank-transactions/{subaccountId} still returns {"items": []}: the postings are dated before the link was created, so the completion sync left them on the provider side (see step 3). Step 7 pulls them in.
6. Authorize a card transaction
Give the bank posting something to settle. The expired_auth scenario posts a single Visa authorization at MARRIOTT and never clears it, which is exactly what a card looks like between the swipe and the network's clearing. Pass the amount explicitly (minor units) so it is pinned to the 200.00 bank posting from step 3:
curl -X POST https://api.sandbox.astrada.co/simulate/transaction/scenario \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"scenario": "expired_auth",
"subaccountId": "{subaccountId}",
"cardId": "{cardId}",
"amount": 20000
}'Response (202 Accepted): the published messageIds. A second later the receiver gets a transactionmessage.created (AUTH_REQU) and a transaction.created for the new pending transaction, trimmed here:
{
"id": "e51596a0-96ef-4f33-ad63-0710d98499e5",
"status": "PENDING",
"network": "VISA",
"descriptor": "MARRIOTT",
"cardId": "b1daccbe-65b5-4a93-a174-95bbbe21500a",
"transactionHoldAmount": 200,
"transactionSettledAmount": 0,
"transactionCurrency": "USD",
"transactionOccurrenceDate": "2026-09-02",
"_embedded": {
"messages": [
{
"id": "d427f4ef-ebb3-46cc-91e6-7c9442f280d5",
"messageType": "AUTH_REQU",
"network": "VISA",
"transactionReference": "609128508919367",
"approvalCode": "PUFF2W",
"transactionAmount": 200,
"bankData": null
}
]
}
}GET /transactions?cardId={cardId} shows the same transaction. Keep its id: step 7 settles it.
7. Pull the bank postings and watch the unified feed
Ask the connection for its history (scope banking:admin). This pulls the two fixture postings, runs matching, and emits everything that follows:
curl -X POST "https://api.sandbox.astrada.co/bank-accounts/{bankAccountId}/backfill" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{}'Response (201 Created):
{
"id": "4f4ca7ed-919e-47f9-8b27-7b685d3d92a5",
"transactionsProcessed": 1,
"matchesFound": 0
}The counters are a snapshot: matching and the rest of the sync finish asynchronously right behind this response, so read the outcome from the webhooks and the listings below rather than from these numbers. For Plaid the default First Platypus accounts carry a rich synthetic history, and override_accounts carries whatever you declared. For Mastercard US accounts, FinBank history must first be generated on the provider side: send {"loadHistoric": true} in the body (asynchronous, and billable against real providers in production; harmless in sandbox). New postings also flow in continuously: sandbox Plaid links are refreshed about every 5 minutes.
Within seconds the receiver has two unified-feed events.
Earlier settlement. The 200.00 posting matched the pending MARRIOTT authorization with HIGH confidence, so the transaction from step 6 settled, days before a card network would have cleared it. transaction.updated, trimmed:
{
"id": "e51596a0-96ef-4f33-ad63-0710d98499e5",
"status": "SETTLED",
"network": "VISA",
"descriptor": "MARRIOTT",
"cardId": "b1daccbe-65b5-4a93-a174-95bbbe21500a",
"transactionHoldAmount": 0,
"transactionSettledAmount": 200,
"transactionCurrency": "USD",
"_embedded": {
"messages": [
{
"id": "d427f4ef-ebb3-46cc-91e6-7c9442f280d5",
"messageType": "AUTH_REQU",
"network": "VISA",
"transactionReference": "609128508919367",
"approvalCode": "PUFF2W",
"transactionAmount": 200,
"bankData": null
},
{
"id": "09559c0d-e530-5757-9524-53a06a718f7d",
"messageType": "FINL_ADVC",
"network": "VISA",
"transactionReference": "609128508919367",
"approvalCode": "PUFF2W",
"transactionAmount": 200,
"bankData": {
"bankTransactionId": "60b9eca8-b937-442f-a8b2-b39b9a4604a4",
"provider": "plaid",
"personalFinanceCategory": { "primary": "TRAVEL", "detailed": "TRAVEL_LODGING" }
}
}
]
}
}The settling message is a bank-sourced FINL_ADVC that inherits the authorization's network, transactionReference and approvalCode; the bankData block is what tells you a bank posting settled it. When the network's own clearing arrives later, amounts may revise once more and the bank message is marked superseded; the network stays the financial authority.
Bank-only movement. The 12.75 parking fee has no card counterpart, so it becomes a transaction of its own, attributed to the minted card. transaction.created, trimmed:
{
"id": "f5bcf183-4d9c-4f2d-86c0-865fc92f06d5",
"status": "SETTLED",
"network": "OPEN_BANKING",
"descriptor": "Parking Garage",
"cardId": "b1daccbe-65b5-4a93-a174-95bbbe21500a",
"transactionHoldAmount": 0,
"transactionSettledAmount": 12.75,
"transactionCurrency": "USD",
"transactionOccurrenceDate": "2026-09-01",
"_embedded": {
"messages": [
{
"id": "3576b219-4769-5608-8ed3-eb5af8b8efca",
"messageType": "FINL_ADVC",
"network": "OPEN_BANKING",
"transactionReference": "bank_PqWA1aQzGPF4RdW5AXKqHdMQNe4NJecXXgBPG",
"approvalCode": null,
"transactionAmount": 12.75,
"bankData": {
"bankTransactionId": "cea1a798-477c-4585-ac1d-e0eaf83240df",
"provider": "plaid",
"personalFinanceCategory": { "primary": "TRANSPORTATION", "detailed": "TRANSPORTATION_PARKING" }
}
}
]
}
}The markers of a bank-only movement are the same everywhere: no approvalCode, a bank_-prefixed transactionReference, a bankData block, and network: "OPEN_BANKING". Each of these messages also arrived as its own transactionmessage.created, with the same fields.
Both transactions are now in the unified listing:
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
"https://api.sandbox.astrada.co/transactions?subaccountId={subaccountId}"Response (200 OK): the settled MARRIOTT transaction (network: "VISA") and the parking transaction (network: "OPEN_BANKING"), both carrying {cardId}.
One join key across every surface
bankData.bankTransactionIdequals theidinbanktransaction.createdand intransaction.match.created.matches[], and resolves viaGET /bank-transactions/{subaccountId}/{id}. Stitch the bank feed and the unified transaction into one record with no extra REST call.
The reconciliation surface fired alongside: one banktransaction.created per posting, and the bank transaction listing shows the match and the settlement it produced:
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
"https://api.sandbox.astrada.co/bank-transactions/{subaccountId}?limit=10"Response (200 OK), trimmed: the matched posting carries the card, the match, and status: "early_cleared"; the unmatched one stays settled with no card:
{
"items": [
{
"id": "60b9eca8-b937-442f-a8b2-b39b9a4604a4",
"date": "2026-09-01",
"amount": 200,
"currency": "USD",
"description": "Marriott International",
"cardId": "b1daccbe-65b5-4a93-a174-95bbbe21500a",
"matchId": "1145fbbf-12f9-4fd8-a96f-abd0997708c9",
"status": "early_cleared",
"settlement": { "state": "early_cleared", "emissionType": "early_clearing" }
},
{
"id": "cea1a798-477c-4585-ac1d-e0eaf83240df",
"date": "2026-09-01",
"amount": 12.75,
"currency": "USD",
"description": "CITY PARKING GARAGE",
"cardId": null,
"matchId": null,
"status": "settled"
}
]
}curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
"https://api.sandbox.astrada.co/transaction-matches/{subaccountId}?minConfidence=LOW"Response (200 OK), trimmed:
{
"items": [
{
"id": "1145fbbf-12f9-4fd8-a96f-abd0997708c9",
"confidence": "HIGH",
"score": 0.95,
"cardTransactionId": "d427f4ef-ebb3-46cc-91e6-7c9442f280d5",
"bankTransactionId": "60b9eca8-b937-442f-a8b2-b39b9a4604a4",
"reasons": [
{ "type": "Amount", "score": 1, "message": "Exact match" },
{ "type": "Date", "score": 0.95, "message": "1 day apart" },
{ "type": "Merchant", "score": 1, "message": "Exact match" }
]
}
]
}cardTransactionId is the id of the card transaction message the posting matched (the AUTH_REQU from step 6, _embedded.messages[0].id on the unified transaction), not the transaction id e51596a0-… that the transaction.updated payload carries. Matching multiplies amount, date, and merchant similarity, and a date gap over 5 days scores zero, so give your bank fixture a recent date and a merchant that resembles the scenario's: expired_auth authorizes at MARRIOTT (200.00 USD by default) and simple_purchase clears at BLUE BOTTLE COFFEE (4.50 USD by default). The scenario list is in the Sandbox API Reference. Only HIGH-confidence matches settle a card transaction early.
To see the match as a webhook, ask for it explicitly (scope banking:admin). The same endpoint re-runs matching when you pass "forceRematch": true, which is the way to reconcile a bank posting that arrived before its card data:
curl -X POST "https://api.sandbox.astrada.co/bank-transactions/{subaccountId}/webhooks" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"ids": ["{bankTransactionId}"],
"webhooks": ["transaction.match.created"]
}'Response (200 OK):
{
"message": "1 match webhook(s) resent",
"matched": 0,
"emitted": 0,
"resent": 1,
"skipped": 0,
"failed": []
}The transaction.match.created payload carries the card transaction, the matched bank transaction (matches[].id is the join key above), the confidence, and the same reasons.
8. Webhooks checklist
By this point your receiver should have, in this order:
| Event | Fired at |
|---|---|
cardsubscription.created | Step 3/4 completion, one per minted bank-feed card |
banklink.completed | Same completion, with all discovered accounts |
transactionmessage.created (AUTH_REQU) / transaction.created (PENDING) | Step 6, the simulated card authorization |
banktransaction.created | Step 7, one per pulled bank posting |
transactionmessage.created (FINL_ADVC, bankData) | Step 7, one per posting: the early clearing on the card's reference, and the bank-only movement on a bank_ reference |
transaction.updated (SETTLED) | Step 7, the card authorization settled by the matching posting |
transaction.created (SETTLED, OPEN_BANKING) | Step 7, the bank-only movement |
transaction.match.created | Step 7, when you request it (and on matches found by the periodic sync) |
bankaccount.state_changed fires later, when a connection needs attention (auth_required) or is disconnected. Payload shapes are in Bank Linking Webhooks & Events.
9. Clean up
curl -X DELETE "https://api.sandbox.astrada.co/bank-links/sandbox-walkthrough-1" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"Response (204 No Content): the link, its bank accounts, and their transactions are deleted, and the provider connection is revoked. Deleting the subaccount itself (DELETE /subaccounts/{subaccountId}) also clears its bank links, so a single call tears the whole test subaccount down; see Cleaning up test data.
Next steps
- Bank Linking Webhooks & Events: every event in detail, including what happens when the network clearing lands after a bank settlement, and when a bank movement arrives before its card data.
- Bank Linking: the production integration guides.
- Linking Cards to Bank Accounts: manual card linking and backfill semantics.
- Sandbox API Reference: the simulation endpoint reference.
Updated 14 days ago
