Skip to main content

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

  1. User provides wallet address - Get Spark wallet address from user
  2. Create quote - Generate quote for fiat-to-crypto or crypto-to-crypto transfer
  3. Execute transfer - Send crypto directly to user’s wallet
  4. 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

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 };
}
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;
}
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

I