Skip to main content
Plaid integration allows your customers to securely connect their bank accounts without manually entering account numbers and routing information. Grid handles the complete Plaid Link flow, automatically creating external accounts when customers authenticate their banks.
Plaid integration requires Grid to manage your Plaid configuration. Contact support to enable Plaid for your platform.

Overview

The Plaid flow involves collaboration between your platform, Grid, Plaid, and the customer’s bank:
  1. Request link token: Your platform requests a Plaid Link token from Grid for a specific customer
  2. Initialize Plaid Link: Display Plaid Link UI to your customer using the link token
  3. Customer authenticates: Customer selects their bank and authenticates using Plaid Link
  4. Exchange tokens: Plaid returns a public token; your platform sends it to Grid’s callback URL
  5. Async processing: Grid exchanges the public token with Plaid and retrieves account details
  6. External account created: Grid creates the external account and sends a webhook notification. The external account is available for transfers and payments
To initiate the Plaid flow, request a link token from Grid:
curl -X POST 'https://api.lightspark.com/grid/2025-10-13/plaid/link-tokens' \
  -H 'Authorization: Basic $GRID_CLIENT_ID:$GRID_CLIENT_SECRET' \
  -H 'Content-Type: application/json' \
  -d '{
    "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001"
  }'
Response:
{
  "linkToken": "link-sandbox-af1a0311-da53-4636-b754-dd15cc058176",
  "expiration": "2025-10-05T18:30:00Z",
  "callbackUrl": "https://api.lightspark.com/grid/2025-10-13/plaid/callback/link-sandbox-af1a0311-da53-4636-b754-dd15cc058176",
  "requestId": "req_abc123def456"
}
Store the callbackUrl when you request the link token so you can retrieve it later when exchanging the public token.

Key response fields:

  • linkToken: Use this to initialize Plaid Link in your frontend
  • callbackUrl: Where to POST the public token after Plaid authentication completes. The URL follows the pattern https://api.lightspark.com/grid/{version}/plaid/callback/{linkToken}. While you can construct this manually, we recommend using the provided URL for forward compatibility.
  • expiration: Link tokens typically expire after 4 hours
  • requestId: Unique identifier for debugging purposes
Link tokens are single-use and will expire. If the customer doesn’t complete the flow, you’ll need to request a new link token.
Display the Plaid Link UI to your customer using the link token. The implementation varies by platform:
Install the appropriate Plaid SDK for your platform:
  • React: npm install react-plaid-link
  • React Native: npm install react-native-plaid-link-sdk
  • Vanilla JS: Include the Plaid script tag as shown above
  • React
  • React Native
  • Vanilla JavaScript
import { usePlaidLink } from 'react-plaid-link';

function BankAccountConnector({ linkToken, onSuccess }) {
const { open, ready } = usePlaidLink({
token: linkToken,
onSuccess: async (publicToken, metadata) => {
console.log('Plaid authentication successful');

      // Send public token to YOUR backend endpoint
      await fetch('/api/plaid/exchange-token', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          publicToken: publicToken,
          accountId: metadata.account_id, // Optional
        }),
      });

      onSuccess();
    },
    onExit: (error, metadata) => {
      if (error) {
        console.error('Plaid Link error:', error);
      }
      console.log('User exited Plaid Link');
    },

});

return (

<button onClick={() => open()} disabled={!ready}>
  Connect your bank account
</button>
); }

Exchange the public token on your backend

Create a backend endpoint that receives the public token from your frontend and forwards it to Grid’s callback URL:
// Backend endpoint: POST /api/plaid/exchange-token
app.post('/api/plaid/exchange-token', async (req, res) => {
  const { publicToken, accountId } = req.body;
  const customerId = req.user.gridCustomerId; // From your auth

  try {
    // Get the callback URL (you stored this when requesting the link token)
    const callbackUrl = await getStoredCallbackUrl(customerId);

    // Forward to Grid's callback URL with proper authentication
    const response = await fetch(callbackUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        publicToken: publicToken,
        accountId: accountId,
      }),
    });

    if (!response.ok) {
      throw new Error(`Grid API error: ${response.status}`);
    }

    const result = await response.json();
    res.json({ success: true, message: result.message });
  } catch (error) {
    console.error('Error exchanging token:', error);
    res.status(500).json({ error: 'Failed to process bank account' });
  }
});
Response from Grid (HTTP 202 Accepted):
{
  "message": "External account creation initiated. You will receive a webhook notification when complete.",
  "requestId": "req_def456ghi789"
}
A 202 Accepted response indicates Grid has received the token and is processing it asynchronously. The external account will be created in the background.

Handle webhook notification

After Grid creates the external account, you’ll receive an ACCOUNT_STATUS webhook.
{
  "type": "ACCOUNT_STATUS",
  "timestamp": "2025-01-15T14:32:10Z",
  "webhookId": "Webhook:019542f5-b3e7-1d02-0000-0000000000ac",
  "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001",
  "account": {
    "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123",
    "status": "ACTIVE",
    "currency": "USD",
    "platformAccountId": "user_123_primary_bank",
    "accountInfo": {
      "accountType": "US_ACCOUNT",
      "accountNumber": "123456789",
      "routingNumber": "021000021",
      "accountCategory": "CHECKING",
      "bankName": "Chase Bank",
      "beneficiary": {
        "beneficiaryType": "INDIVIDUAL",
        "fullName": "John Doe",
        "birthDate": "1990-01-15",
        "nationality": "US",
        "address": {
          "line1": "123 Main Street",
          "city": "San Francisco",
          "state": "CA",
          "postalCode": "94105",
          "country": "US"
        }
      }
    }
  }
}

Error handling

Handle common error scenarios:
const { open } = usePlaidLink({
  token: linkToken,
  onExit: (error, metadata) => {
    if (error) {
      console.error("Plaid error:", error);
      // Show user-friendly error message
      setError("Unable to connect to your bank. Please try again.");
    } else {
      // User closed the modal without completing
      console.log("User exited without connecting");
    }
  },
});

Using Plaid for ramps

Plaid integration is particularly valuable for off-ramp flows where users convert crypto to fiat and need a bank account destination:

Off-ramp benefits

  • Seamless UX: Users authenticate with their bank directly—no manual account entry
  • Instant verification: Bank accounts are verified in real-time
  • Reduced errors: Eliminates typos in account numbers and routing information
  • Higher conversion: Simplified flow increases completion rates
Plaid is recommended for consumer off-ramp flows where users need to quickly connect a bank account to receive fiat currency.

When to use Plaid

Use Plaid integration when:
  • Consumer off-ramps: Individual users converting crypto to USD/fiat
  • First-time users: Simplifying the initial bank account setup
  • Mobile apps: Plaid’s mobile SDKs provide native integration
  • Compliance: Plaid’s bank authentication provides additional verification

When to use manual entry

Manual bank account entry may be preferred for:
  • Business accounts: Corporate bank accounts often require additional documentation
  • International accounts: Plaid primarily supports US banks (though expanding)
  • Wire transfers: Large amounts that require wire-specific account details
  • Non-supported banks: Some smaller banks or credit unions may not be available via Plaid

Ramp-specific implementation

Off-ramp flow with Plaid

1

User initiates off-ramp

User requests to convert Bitcoin to USD and wants to receive funds in their bank account.
// User clicks "Cash out Bitcoin"
const initiateOffRamp = async (amountSats) => {
  // Check if user has connected bank account
  const hasAccount = await checkExternalBankAccount(userId);
  
  if (!hasAccount) {
    // Trigger Plaid flow
    await connectBankViaPlaid();
  } else {
    // Proceed with quote
    await createOffRampQuote(amountSats);
  }
};
2

Request Plaid link token

Your backend requests a link token for the customer from Grid.
const response = await fetch(
  'https://api.lightspark.com/grid/2025-10-13/plaid/link-tokens',
  {
    method: 'POST',
    headers: {
      'Authorization': `Basic ${gridCredentials}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      customerId: gridCustomerId,
    }),
  }
);

const { linkToken, callbackUrl } = await response.json();
3

Display Plaid Link

Show Plaid Link UI to the user for bank authentication.
const { open } = usePlaidLink({
  token: linkToken,
  onSuccess: async (publicToken, metadata) => {
    // Forward to your backend
    await fetch('/api/plaid/connect-bank', {
      method: 'POST',
      body: JSON.stringify({
        publicToken,
        accountId: metadata.account_id,
      }),
    });
    
    // Show success message
    setMessage('Bank connected! Processing your withdrawal...');
  },
});
4

Wait for webhook

Grid creates the external account and sends a webhook notification.
// Your webhook handler
if (webhookPayload.type === 'ACCOUNT_STATUS' && webhookPayload.account) {
  const { account, customerId } = webhookPayload;
  
  // Bank account is ready for off-ramp
  await db.users.update({
    where: { gridCustomerId: customerId },
    data: { bankAccountId: account.accountId },
  });
  
  // Automatically proceed with off-ramp if amount is pending
  const pendingOffRamp = await db.offRamps.findPending(customerId);
  if (pendingOffRamp) {
    await createOffRampQuote({
      customerId,
      sourceAccountId: pendingOffRamp.btcAccountId,
      destinationAccountId: account.accountId,
      amountSats: pendingOffRamp.amountSats,
    });
  }
}

Combining Plaid with quote creation

For a seamless user experience, combine Plaid bank connection with immediate quote execution:
// Complete off-ramp flow
async function executeOffRamp({ userId, amountSats }) {
  const customer = await getCustomer(userId);

  // Check for existing bank account
  let bankAccountId = customer.bankAccountId;

  if (!bankAccountId) {
    // Initiate Plaid flow
    const plaidToken = await requestPlaidLinkToken(customer.gridCustomerId);

    // Wait for user to complete Plaid (via frontend)
    await waitForPlaidCompletion(userId);

    // Refresh customer to get bank account ID
    const updated = await getCustomer(userId);
    bankAccountId = updated.bankAccountId;
  }

  // Create off-ramp quote
  const quote = await createQuote({
    source: {
      accountId: customer.btcInternalAccountId, // User's BTC balance
    },
    destination: {
      accountId: bankAccountId, // Plaid-connected bank account
      currency: "USD",
    },
    lockedCurrencySide: "SENDING",
    lockedCurrencyAmount: amountSats,
  });

  // Execute quote
  await executeQuote(quote.id);

  return quote;
}

Advanced patterns

Improve UX by prefetching link tokens before users request off-ramps:
// On app load or settings page
useEffect(() => {
  async function prefetchPlaidToken() {
    if (!user.hasBankAccount && !sessionStorage.getItem("plaidLinkToken")) {
      const response = await fetch("/api/plaid/link-token");
      const { linkToken } = await response.json();
      sessionStorage.setItem("plaidLinkToken", linkToken);
    }
  }

  prefetchPlaidToken();
}, [user]);

// When user clicks "Cash out", token is already available
const startOffRamp = () => {
  const linkToken = sessionStorage.getItem("plaidLinkToken");
  if (linkToken) {
    plaidLinkHandler.open();
  }
};
Link tokens expire after 4 hours, so prefetch close to when you expect users to need them.

Handling multiple bank accounts

Allow users to connect multiple bank accounts for different off-ramp scenarios:
// List all Plaid-connected accounts
async function listBankAccounts(userId) {
  const customer = await getCustomer(userId);
  const accounts = await fetch(
    `https://api.lightspark.com/grid/2025-10-13/customers/external-accounts?customerId=${customer.gridCustomerId}&accountType=US_ACCOUNT`,
    { headers: { Authorization: `Basic ${gridCredentials}` } }
  );

  return accounts.json();
}

// Let user choose destination for off-ramp
const selectBankForOffRamp = async (amountSats) => {
  const banks = await listBankAccounts(userId);

  if (banks.data.length === 0) {
    // No banks connected, trigger Plaid
    await connectNewBank();
  } else {
    // Show bank selection UI
    showBankSelector(banks.data, (selectedBank) => {
      createOffRampQuote({
        destinationAccountId: selectedBank.id,
        amountSats,
      });
    });
  }
};

Error recovery

Gracefully handle Plaid errors and offer alternatives:
const { open } = usePlaidLink({
  token: linkToken,
  onSuccess: handleSuccess,
  onExit: (error, metadata) => {
    if (error) {
      console.error("Plaid error:", error);

      // Show error-specific messaging
      if (error.error_code === "ITEM_LOGIN_REQUIRED") {
        setError(
          "Your bank requires you to log in again. Please try reconnecting."
        );
      } else if (error.error_code === "INSTITUTION_NOT_RESPONDING") {
        setError(
          "Your bank is temporarily unavailable. Please try again later or connect manually."
        );
        // Offer manual entry option
        setShowManualEntry(true);
      } else {
        setError(
          "Unable to connect to your bank. Would you like to enter your account details manually?"
        );
        setShowManualEntry(true);
      }
    } else {
      // User exited without completing
      console.log("User closed Plaid without connecting");
    }
  },
});

{
  showManualEntry && <ManualBankAccountForm onSubmit={handleManualEntry} />;
}

Best practices for ramps

Plaid’s mobile SDKs provide the best UX for app-based off-ramps:
// React Native example
import { PlaidLink } from "react-native-plaid-link-sdk";

<PlaidLink
  tokenConfig={{ token: linkToken }}
  onSuccess={async ({ publicToken }) => {
    await submitToBackend(publicToken);
    // Navigate to off-ramp confirmation
    navigation.navigate("OffRampConfirm");
  }}
>
  <TouchableOpacity style={styles.bankButton}>
    <Text>Connect Bank Account</Text>
  </TouchableOpacity>
</PlaidLink>;
Store Plaid-connected accounts to avoid re-connection:
// After successful Plaid connection
const handlePlaidSuccess = async (account) => {
  // Store reference in your DB
  await db.users.update({
    where: { id: userId },
    data: {
      primaryBankAccountId: account.id,
      bankAccountLastFour: account.accountInfo.accountNumber.slice(-4),
      bankName: account.accountInfo.bankName,
    },
  });

  // Show in UI without re-fetching
  setBankAccount({
    id: account.id,
    lastFour: account.accountInfo.accountNumber.slice(-4),
    bankName: account.accountInfo.bankName,
  });
};
Keep users informed during async bank account creation:
// Show loading state while waiting for webhook
const [accountStatus, setAccountStatus] = useState("connecting");

useEffect(() => {
  // After Plaid success
  if (plaidConnected) {
    setAccountStatus("verifying");

    // Poll for account or wait for WebSocket
    const pollInterval = setInterval(async () => {
      const account = await checkBankAccountStatus(userId);
      if (account?.status === "ACTIVE") {
        setAccountStatus("ready");
        clearInterval(pollInterval);
      }
    }, 2000);

    return () => clearInterval(pollInterval);
  }
}, [plaidConnected]);

// Show appropriate UI
{
  accountStatus === "verifying" && (
    <div className="status-banner">
      <Spinner />
      <p>Verifying your bank account...</p>
    </div>
  );
}

Next steps

I