Add Bank Account With Plaid Link

Transactional Graph Service exposes a public GraphQL wrapper for the Fluz Plaid v2 integration. Identity-service remains the system of record for Plaid tokens, bank account ingestion, balances, historical transactions, and Plaid webhooks.

Auth

Call /api/v1/graphql with a Fluz user Bearer access token that includes MANAGE_PAYMENT.

These mutations do not allow Basic auth. Developer applications should use the existing TGS authentication flow to obtain a user access token first, then call the Plaid mutations with:

Authorization: Bearer <fluz-user-access-token>

New Bank Link Flow

Use this flow when the user is adding a new bank connection.

  1. Before opening Plaid Link, create a Link token.
mutation CreatePlaidLinkToken($input: CreatePlaidLinkTokenInput!) {
  createPlaidLinkToken(input: $input) {
    linkToken
    expiration
    requestId
    mode
  }
}
{
  "input": {}
}

For native Link, pass deviceOs as IOS or ANDROID so identity-service can include the correct Plaid OAuth redirect option.

{
  "input": {
    "deviceOs": "IOS"
  }
}
  1. Initialize Plaid Link with linkToken.

  2. In Plaid Link onSuccess, send the returned public_token to TGS.

mutation CompletePlaidLink($input: CompletePlaidLinkInput!) {
  completePlaidLink(input: $input) {
    requiresAddress
    bankAccountId
    bankInstitutionAuthId
    newlyLinkedBankInstitutionAuthId
    bankInstitutionName
    platformItemId
    bankAccounts {
      bankInstitutionAuthId
      bankAccountId
      bankName
      lastFour
      type
      subtype
    }
  }
}
{
  "input": {
    "publicToken": "public-sandbox-..."
  }
}
  1. Store platformItemId from the completion response. This is the safe public identifier to use later for relinking. If it is not returned, call getPlaidBankAccounts for the user and store the persisted platformItemId from that response.

Relink Flow

Use this flow when a previously linked Plaid connection is disconnected and needs repair.

  1. Start Link token creation with the stored platformItemId.
mutation CreatePlaidLinkToken($input: CreatePlaidLinkTokenInput!) {
  createPlaidLinkToken(input: $input) {
    linkToken
    expiration
    requestId
    mode
  }
}
{
  "input": {
    "platformItemId": "plaid-item-id"
  }
}

TGS uses platformItemId to retrieve the Plaid access token from identity-service, then asks identity-service to create a Plaid update-mode Link token. The access token is never returned to the caller.

For native relink, also pass deviceOs as IOS or ANDROID.

  1. Initialize Plaid Link with the returned linkToken.

  2. In Plaid Link onSuccess, complete the flow with both publicToken and the same platformItemId.

mutation CompletePlaidLink($input: CompletePlaidLinkInput!) {
  completePlaidLink(input: $input) {
    requiresAddress
    bankAccountId
    bankInstitutionAuthId
    newlyLinkedBankInstitutionAuthId
    bankInstitutionName
    platformItemId
    bankAccounts {
      bankInstitutionAuthId
      bankAccountId
      bankName
      lastFour
      type
      subtype
    }
  }
}
{
  "input": {
    "publicToken": "public-sandbox-...",
    "platformItemId": "plaid-item-id"
  }
}
  1. Update stored platformItemId from the response if it changed. If it is not returned, call getPlaidBankAccounts for the user and store the persisted platformItemId from that response.

Plaid Web SDK Example

Plaid's Web SDK script must be loaded directly from Plaid's CDN.

<script src="https://cdn.plaid.com/link/v2/stable/link-initialize.js"></script>

Then create a Link token through TGS, initialize Plaid Link with that token, and pass Plaid's public_token back to TGS in onSuccess.

const FLUZ_GRAPHQL_URL = '<fluz-api-base-url>/api/v1/graphql';
const fluzUserAccessToken = '<fluz-user-access-token>';

const fluzGraphql = async (query, variables) => {
  const response = await fetch(FLUZ_GRAPHQL_URL, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${fluzUserAccessToken}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ query, variables }),
  });

  const body = await response.json();
  if (!response.ok || body.errors?.length) {
    throw new Error(body.errors?.[0]?.message ?? 'Fluz GraphQL request failed');
  }

  return body.data;
};

const createPlaidLinkToken = async (platformItemId) => {
  const data = await fluzGraphql(
    `
      mutation CreatePlaidLinkToken($input: CreatePlaidLinkTokenInput!) {
        createPlaidLinkToken(input: $input) {
          linkToken
          mode
        }
      }
    `,
    { input: { platformItemId } },
  );

  return data.createPlaidLinkToken;
};

const completePlaidLink = async (publicToken, platformItemId) => {
  const data = await fluzGraphql(
    `
      mutation CompletePlaidLink($input: CompletePlaidLinkInput!) {
        completePlaidLink(input: $input) {
          requiresAddress
          bankInstitutionAuthId
          newlyLinkedBankInstitutionAuthId
          bankAccountId
          platformItemId
          bankAccounts {
            bankInstitutionAuthId
            bankAccountId
            bankName
            lastFour
            type
            subtype
          }
        }
      }
    `,
    { input: { publicToken, platformItemId } },
  );

  return data.completePlaidLink;
};

const getPlaidBankAccounts = async (input) => {
  const data = await fluzGraphql(
    `
      query GetPlaidBankAccounts($input: PlaidBankAccountFilterInput) {
        getPlaidBankAccounts(input: $input) {
          platformItemId
          bankInstitutionAuthId
          bankAccountId
          bankInstitutionName
          lastFour
          type
          subtype
        }
      }
    `,
    { input },
  );

  return data.getPlaidBankAccounts;
};

const resolvePlatformItemId = async (completion) => {
  if (completion.platformItemId) return completion.platformItemId;

  const bankInstitutionAuthId = completion.bankInstitutionAuthId ?? completion.newlyLinkedBankInstitutionAuthId;
  const accounts = await getPlaidBankAccounts({ bankInstitutionAuthId });
  return accounts[0]?.platformItemId;
};

const startPlaidLink = async (platformItemId) => {
  const { linkToken } = await createPlaidLinkToken(platformItemId);

  const handler = window.Plaid.create({
    token: linkToken,
    onSuccess: async (publicToken, metadata) => {
      const result = await completePlaidLink(publicToken, platformItemId);
      const persistedPlatformItemId = await resolvePlatformItemId(result);

      await saveConnectionForRelinkLater({
        platformItemId: persistedPlatformItemId,
        bankInstitutionAuthId: result.bankInstitutionAuthId ?? result.newlyLinkedBankInstitutionAuthId,
        bankAccounts: result.bankAccounts,
        plaidLinkSessionId: metadata.link_session_id,
      });
    },
    onExit: (error, metadata) => {
      console.log('Plaid Link exited', { error, metadata });
    },
  });

  handler.open();
};

Call startPlaidLink() for a new bank link. Call startPlaidLink(existingPlatformItemId) to relink a disconnected Plaid connection.

Bank Account And Balance APIs

List safe Plaid bank accounts for the authenticated account.

query GetPlaidBankAccounts($input: PlaidBankAccountFilterInput) {
  getPlaidBankAccounts(input: $input) {
    platformItemId
    bankInstitutionAuthId
    bankInstitutionName
    bankAccountId
    accountName
    lastFour
    type
    subtype
    status
  }
}
{
  "input": {
    "platformItemId": "plaid-item-id"
  }
}

Get latest stored balances.

query GetPlaidBankBalances($input: PlaidBankBalanceFilterInput) {
  getPlaidBankBalances(input: $input) {
    platformItemId
    bankInstitutionAuthId
    bankAccountId
    amount
    current
    source
    trigger
    startedAt
  }
}

Request a cached refresh for all connected Plaid bank data for the authenticated account.

mutation RefreshPlaidBankConnections {
  refreshPlaidBankConnections {
    status
    balances {
      bankAccountId
      amount
      current
    }
    verifyMembers {
      platformItemId
      bankInstitutionAuthId
    }
  }
}

If refreshPlaidBankConnections.verifyMembers is returned, use the platformItemId to start the relink flow.
The public API intentionally does not expose Plaid realtime balance refresh. Use getPlaidBankBalances for the
latest stored balances, and refreshPlaidBankConnections when the app needs identity-service to perform its cached
connection refresh.

Transaction History

Read historical bank transactions populated by identity-service.

query GetPlaidBankTransactions($input: PlaidBankTransactionFilterInput) {
  getPlaidBankTransactions(input: $input) {
    totalCount
    transactions {
      historicalBankTransactionId
      platformItemId
      bankInstitutionAuthId
      bankAccountId
      transactionId
      transactionStatus
      transactionType
      transactionDate
      amount
      currencyCode
      description
      merchantName
      category
      pending
    }
  }
}
{
  "input": {
    "bankAccountId": "bank-account-id",
    "startDate": "2026-01-01T00:00:00.000Z",
    "endDate": "2026-06-04T23:59:59.999Z",
    "paginate": {
      "limit": 50,
      "offset": 0
    }
  }
}

Address And Removal

If completePlaidLink returns requiresAddress: true, attach an address to the linked bank institution.

mutation CreatePlaidLinkAddress($input: CreatePlaidLinkAddressInput!) {
  createPlaidLinkAddress(input: $input) {
    addressId
  }
}
{
  "input": {
    "bankInstitutionAuthId": "bank-institution-auth-id",
    "address": {
      "streetAddressLine1": "123 Main St",
      "streetAddressLine2": "Apt 4",
      "city": "New York",
      "state": "NY",
      "postalCode": "10001",
      "country": "US"
    }
  }
}

Remove a Plaid bank institution.

mutation RemovePlaidBankInstitution($input: RemovePlaidBankInstitutionInput!) {
  removePlaidBankInstitution(input: $input) {
    removed
    bankInstitutionAuthId
  }
}
{
  "input": {
    "bankInstitutionAuthId": "bank-institution-auth-id",
    "reason": "USER_REQUESTED"
  }
}

Security Boundaries

TGS never returns Plaid access tokens, Plaid public tokens, identity-match details, bank account numbers, or routing numbers. Bank account names and nicknames may be user-entered banking labels and are returned only as account metadata.

Identity-service remains responsible for:

  • Plaid token exchange
  • Plaid access token storage
  • Bank institution and bank account creation or repair
  • Balance fetching
  • Historical transaction ingestion
  • Bank institution removal
  • Plaid webhooks

TGS does not expose identity-service directly and does not add a public Plaid webhook route.

Unsupported Flows

Manual Plaid micro-deposit linking is intentionally unsupported by this public wrapper. New links and relinks must use the normal Plaid Link flow that supports balances and historical transactions.

If a user starts a new link for the same institution instead of choosing relink, complete it as a normal new link. Identity-service owns the bank account dedupe and repair behavior; store the returned platformItemId after completion.

Error Handling

If createPlaidLinkToken fails with No active Plaid connection found for the provided platformItemId., the caller should treat the stored connection as no longer relinkable and guide the user through a new bank link.

If completePlaidLink times out or fails after Plaid returned a public_token, first call getPlaidBankAccounts using the stored or expected platformItemId before retrying. Retry only if Plaid returns a fresh public_token from a new Link session. Plaid public tokens are short-lived and single-use.