Overview
Grid supports sending Bitcoin via Lightning Network to self-custody wallets using Spark wallet addresses. This enables users to maintain full control of their crypto while benefiting from Grid’s fiat-to-crypto conversion and payment rails.
Spark wallets use the Lightning Network for instant, low-cost Bitcoin
transactions. Users can receive payments directly to their self-custody
wallets without Grid holding their funds.
How it works
User provides wallet address - Get Spark wallet address from user
Create quote - Generate quote for fiat-to-crypto or crypto-to-crypto transfer
Execute transfer - Send crypto directly to user’s wallet
Instant settlement - Lightning Network provides near-instant confirmation
Prerequisites
Customer created in Grid
Valid Spark wallet address from user
Webhook endpoint for payment notifications (optional, for status updates)
Sending crypto to self-custody wallets
Step 1: Collect wallet address
Request the user’s Spark wallet address. Spark addresses start with spark1
:
function validateSparkAddress ( address ) {
// Spark addresses start with spark1 and are typically 90+ characters
if ( ! address . startsWith ( "spark1" )) {
throw new Error ( "Invalid Spark wallet address" );
}
if ( address . length < 90 ) {
throw new Error ( "Spark address appears incomplete" );
}
return address ;
}
// Example valid address
const sparkAddress =
"spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu" ;
Always validate wallet addresses before creating quotes. Invalid addresses
will cause transaction failures and potential fund loss.
Step 2: Create external account for the wallet
Register the Spark wallet as an external account:
curl -X POST 'https://api.lightspark.com/grid/2025-10-13/customers/external-accounts' \
-H 'Authorization: Basic $GRID_CLIENT_ID:$GRID_CLIENT_SECRET' \
-H 'Content-Type: application/json' \
-d '{
"customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001",
"currency": "BTC",
"beneficiary": {
"counterPartyType": "INDIVIDUAL",
"fullName": "John Doe",
"email": "john@example.com"
},
"accountInfo": {
"accountType": "SPARK_WALLET",
"address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu"
}
}'
Store the external account ID for future transfers. Users can reuse the same
wallet address for multiple transactions.
Step 3: Create and execute a quote
Option A: From fiat to self-custody wallet
Convert fiat directly to crypto in user’s wallet:
// Create quote for fiat-to-crypto
const quote = await fetch ( "https://api.lightspark.com/grid/2025-10-13/quotes" , {
method: "POST" ,
body: JSON . stringify ({
source: {
customerId: "Customer:019542f5-b3e7-1d02-0000-000000000001" ,
currency: "USD" ,
},
destination: {
accountId: externalAccount . id ,
currency: "BTC" ,
},
lockedCurrencySide: "SENDING" ,
lockedCurrencyAmount: 10000 , // $100.00
description: "Buy Bitcoin to self-custody wallet" ,
}),
}). then (( r ) => r . json ());
// Display payment instructions to user
console . log ( "Send fiat to:" , quote . paymentInstructions );
console . log ( "Will receive:" , ` ${ quote . receivingAmount / 100000000 } BTC` );
Option B: From internal account to self-custody wallet
Transfer crypto from internal account to user’s wallet:
// Same-currency transfer (no quote needed)
const transaction = await fetch (
"https://api.lightspark.com/grid/2025-10-13/transfer-out" ,
{
method: "POST" ,
body: JSON . stringify ({
source: {
accountId: "InternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" ,
},
destination: {
accountId: externalAccount . id ,
},
amount: 100000 , // 0.001 BTC in satoshis
}),
}
). then (( r ) => r . json ());
console . log ( "Transfer initiated:" , transaction . id );
Step 4: Monitor transfer completion
Track the transfer status via webhooks:
app . post ( "/webhooks/grid" , async ( req , res ) => {
const { type , transaction } = req . body ;
if ( type === "OUTGOING_PAYMENT" ) {
if ( transaction . status === "COMPLETED" ) {
// Notify user of successful transfer
await notifyUser ( transaction . customerId , {
message: "Bitcoin sent to your wallet!" ,
amount: ` ${ transaction . receivedAmount . amount / 100000000 } BTC` ,
walletAddress: transaction . destination . accountInfo . address ,
});
} else if ( transaction . status === "FAILED" ) {
// Handle failure
await notifyUser ( transaction . customerId , {
message: "Transfer failed" ,
reason: transaction . failureReason ,
action: "Please verify your wallet address" ,
});
}
}
res . status ( 200 ). json ({ received: true });
});
Best practices
Validate wallet addresses
Always validate Spark addresses before processing: function validateSparkAddress ( address ) {
// Check format
if ( ! address . startsWith ( "spark1" )) {
return { valid: false , error: "Must start with spark1" };
}
// Check length (typical Spark addresses are 90+ chars)
if ( address . length < 90 ) {
return { valid: false , error: "Address too short" };
}
// Check for common typos
if ( address . includes ( " " ) || address . includes ( " \n " )) {
return { valid: false , error: "Address contains whitespace" };
}
return { valid: true };
}
Handle Lightning Network specifics
Lightning Network has unique characteristics: const lightningLimits = {
minAmount: 1000 , // 0.00001 BTC (1000 satoshis)
maxAmount: 10000000 , // 0.1 BTC (10M satoshis)
settlementTime: "Instant (typically < 10 seconds)" ,
fees: "Very low (typically < 1%)" ,
};
function validateLightningAmount ( satoshis ) {
if ( satoshis < lightningLimits . minAmount ) {
throw new Error ( `Minimum amount is ${ lightningLimits . minAmount } sats` );
}
if ( satoshis > lightningLimits . maxAmount ) {
throw new Error ( `Maximum amount is ${ lightningLimits . maxAmount } sats` );
}
return true ;
}
Provide clear user instructions
Help users understand the process: function getWalletInstructions ( walletType ) {
return {
SPARK_WALLET: {
title: "Lightning Network Wallet" ,
steps: [
"Open your Lightning wallet app" ,
'Select "Send" or "Pay"' ,
"Scan the QR code or paste the address" ,
"Confirm the amount and send" ,
"Funds arrive instantly" ,
],
compatibleWallets: [
"Spark Wallet" ,
"Phoenix" ,
"Breez" ,
"Muun" ,
"Blue Wallet (Lightning)" ,
],
},
};
}
Implement retry logic for common failures: async function handleTransferFailure ( transaction ) {
const { failureReason } = transaction ;
const retryableReasons = [
"TEMPORARY_NETWORK_ERROR" ,
"INSUFFICIENT_LIQUIDITY" ,
"ROUTE_NOT_FOUND" ,
];
if ( retryableReasons . includes ( failureReason )) {
// Retry after delay
await delay ( 5000 );
return await retryTransfer ( transaction . quoteId );
}
// Non-retryable errors
const errorMessages = {
INVALID_ADDRESS:
"The wallet address is invalid. Please verify and try again." ,
AMOUNT_TOO_SMALL:
"Amount is below minimum. Lightning Network requires at least 1000 satoshis." ,
AMOUNT_TOO_LARGE:
"Amount exceeds Lightning Network limits. Consider splitting into multiple transfers." ,
};
return {
canRetry: false ,
message:
errorMessages [ failureReason ] ||
"Transfer failed. Please contact support." ,
};
}
Testing in sandbox
Test self-custody wallet flows in sandbox mode:
// Sandbox: Use test Spark addresses
const testSparkAddress =
"spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu" ;
// Create test transfer
const testTransfer = await fetch (
"https://api.lightspark.com/grid/2025-10-13/quotes" ,
{
method: "POST" ,
body: JSON . stringify ({
source: { accountId: "InternalAccount:..." },
destination: {
externalAccountDetails: {
customerId: "Customer:..." ,
currency: "BTC" ,
beneficiary: {
/* test data */
},
accountInfo: {
accountType: "SPARK_WALLET" ,
address: testSparkAddress ,
},
},
},
lockedCurrencySide: "SENDING" ,
lockedCurrencyAmount: 10000 ,
immediatelyExecute: true ,
}),
}
). then (( r ) => r . json ());
// Simulate completion (sandbox auto-completes)
console . log ( "Test transfer status:" , testTransfer . status );
In sandbox mode, transfers to Spark wallets complete instantly without
requiring actual Lightning Network transactions.
Next steps