Skip to main content

Documentation Index

Fetch the complete documentation index at: https://ramps-06-01-docs-document-business-beneficiary-requirements.mintlify.app/llms.txt

Use this file to discover all available pages before exploring further.

The Grid Sandbox environment provides a complete testing environment for ramp operations, allowing you to validate on-ramp and off-ramp flows without using real money or cryptocurrency.

Sandbox overview

Sandbox mirrors production behavior while using simulated funds:
  • Same API endpoints: Use identical API calls as production
  • Simulated funding: Mock bank transfers and crypto deposits
  • Real webhooks: Receive actual webhook notifications
  • No real money: All transactions use test funds
  • Isolated environment: Sandbox data never affects production
Sandbox is perfect for development, testing, and demonstrating ramp functionality before going live.

Getting started

Create sandbox credentials

  1. Log into the Grid dashboard
  2. Navigate to SettingsAPI Keys
  3. Click Create API Key and select Sandbox environment
  4. Save your API key ID and secret securely
Sandbox credentials only work with the sandbox environment. They cannot access production data or move real funds.

Configure sandbox webhook

Set up a webhook endpoint for sandbox notifications:
curl -X PATCH 'https://api.lightspark.com/grid/2025-10-13/config' \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{
    "webhookEndpoint": "https://api.yourapp.dev/webhooks/grid"
  }'
Use tools like ngrok to expose local webhook endpoints during development: ngrok http 3000

Testing on-ramps (Fiat → Crypto)

Simulate the complete on-ramp flow in sandbox:

Step 1: Create a test customer

curl -X POST 'https://api.lightspark.com/grid/2025-10-13/customers' \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{
    "platformCustomerId": "test_user_001",
    "customerType": "INDIVIDUAL",
    "fullName": "Alice Test",
    "email": "alice@example.com",
    "birthDate": "1990-01-15",
    "address": {
      "line1": "123 Test Street",
      "city": "San Francisco",
      "state": "CA",
      "postalCode": "94105",
      "country": "US"
    }
  }'
In sandbox, customers are automatically approved for testing.

Step 2: Create an external account for the destination wallet

curl -X POST 'https://api.lightspark.com/grid/2025-10-13/customers/external-accounts' \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{
    "customerId": "Customer:sandbox001",
    "currency": "BTC",
    "accountInfo": {
      "accountType": "SPARK_WALLET",
      "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu"
    }
  }'

Step 3: Create an on-ramp quote (just-in-time funding)

curl -X POST 'https://api.lightspark.com/grid/2025-10-13/quotes' \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{
    "source": {
      "sourceType": "REALTIME_FUNDING",
      "customerId": "Customer:sandbox001",
      "currency": "USD"
    },
    "destination": {
      "destinationType": "ACCOUNT",
      "accountId": "ExternalAccount:b23dcbd6-dced-4ec4-b756-3c3a9ea3d456"
    },
    "lockedCurrencySide": "SENDING",
    "lockedCurrencyAmount": 10000,
    "description": "Test on-ramp conversion"
  }'
The quote response includes payment instructions for the fiat funding step.

Step 4: Simulate funding

Use the sandbox endpoint to simulate receiving the fiat payment. Reference the quote by ID and specify the funding currency:
curl -X POST 'https://api.lightspark.com/grid/2025-10-13/sandbox/send' \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{
    "quoteId": "Quote:019542f5-b3e7-1d02-0000-000000000006",
    "currencyCode": "USD"
  }'
currencyCode must match the funding-source currency on the quote. currencyAmount is optional — when omitted, the amount is derived from the quote.

Step 5: Verify completion

Within seconds, you’ll receive a webhook notification confirming the on-ramp completed:
{
  "transaction": {
    "id": "Transaction:sandbox025",
    "status": "COMPLETED",
    "type": "OUTGOING",
    "sentAmount": {
      "amount": 10000,
      "currency": { "code": "USD" }
    },
    "receivedAmount": {
      "amount": 95000,
      "currency": { "code": "BTC" }
    },
    "settledAt": "2025-10-03T15:02:30Z"
  },
  "type": "OUTGOING_PAYMENT"
}

Testing off-ramps (Crypto → Fiat)

Simulate the complete off-ramp flow:

Step 1: Fund internal account with crypto

Simulate a Bitcoin deposit to the customer’s internal account using the sandbox funding endpoint:
curl -X POST 'https://api.lightspark.com/grid/2025-10-13/sandbox/internal-accounts/InternalAccount:btc001/fund' \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{
    "amount": 10000000
  }'
Replace InternalAccount:btc001 with your actual BTC internal account ID.
You’ll receive an INTERNAL_ACCOUNT.BALANCE_UPDATED webhook showing the updated balance.

Step 2: Create external bank account

curl -X POST 'https://api.lightspark.com/grid/2025-10-13/customers/external-accounts' \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{
    "customerId": "Customer:sandbox001",
    "currency": "USD",
    "platformAccountId": "test_bank_001",
    "accountInfo": {
      "accountType": "US_ACCOUNT",
      "accountNumber": "123456001",
      "routingNumber": "021000021",
      "accountCategory": "CHECKING",
      "bankName": "Test Bank",
      "beneficiary": {
        "beneficiaryType": "INDIVIDUAL",
        "fullName": "Alice Test",
        "birthDate": "1990-01-15",
        "nationality": "US",
        "address": {
          "line1": "123 Test Street",
          "city": "San Francisco",
          "state": "CA",
          "postalCode": "94105",
          "country": "US"
        }
      }
    }
  }'
In sandbox, you can use special account number patterns to test different scenarios. The last 3 digits determine the behavior: 002 (insufficient funds), 003 (account closed), 004 (transfer rejected), 005 (timeout/delayed failure). Any other ending succeeds normally. See “Testing transfer failures” below for details.

Step 3: Create and execute off-ramp quote

curl -X POST 'https://api.lightspark.com/grid/2025-10-13/quotes' \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{
    "source": {
      "sourceType": "ACCOUNT",
      "accountId": "InternalAccount:sandbox_btc001"
    },
    "destination": {
      "destinationType": "ACCOUNT",
      "accountId": "ExternalAccount:sandbox_bank001"
    },
    "lockedCurrencySide": "SENDING",
    "lockedCurrencyAmount": 5000000,
    "description": "Test off-ramp conversion"
  }'
Then execute the quote:
curl -X POST 'https://api.lightspark.com/grid/2025-10-13/quotes/{quoteId}/execute' \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET"
In sandbox, off-ramp conversions complete instantly. In production, bank settlement may take 1-3 business days.

Testing transfer failures

External account test patterns

The flows for creating external accounts in sandbox are the same as in production. The last 3 digits of an external account’s primary identifier (account number, IBAN, CLABE, Spark wallet address, etc.) determine the test scenario when that account is used in transfers or quotes. For identifiers with a domain part (e.g. PIX email keys), append the test digits to the username portion — for example, testuser.002@pix.com.br.
SuffixBehavior
002Insufficient funds — transfer fails immediately
003Account closed/invalid — transfer fails immediately
004Transfer rejected — bank rejects the transfer
005Timeout/delayed failure — stays pending ~30s, then fails
Any otherSuccess — transfer completes normally

Beneficiary name verification

For account types that support beneficiary name verification, you can simulate different verification outcomes in sandbox. Use account identifiers with a 1xx suffix to trigger verification scenarios (this range is reserved for verification and does not conflict with transfer or quote test patterns):
SuffixbeneficiaryVerificationStatusBehavior
102NOT_MATCHEDAccount is valid but name does not match
103PARTIAL_MATCHAccount is valid, name is a fuzzy match
104PENDINGVerification still in progress
105(error)Returns 400 — invalid account
106UNSUPPORTEDPayment rail does not support name verification
107CHECKED_BY_RECEIVING_FIVerification deferred to receiving financial institution (e.g., ACH)
Any otherMATCHEDAccount is valid, name matches exactly

Test scenarios

Successful conversions

The complete on-ramp and off-ramp flows described in the sections above demonstrate successful conversion scenarios. For quick reference: On-ramp test (USD → BTC):
  1. Create customer and quote with payment instructions
  2. Use /sandbox/send to simulate funding
  3. Verify completion via webhook
Off-ramp test (BTC → USD):
  1. Fund BTC internal account with /sandbox/internal-accounts/{accountId}/fund
  2. Create external bank account (use default account number for success)
  3. Create and execute quote
  4. Verify completion via webhook

Failed conversions

Test error scenarios systematically using the magic account patterns: 1. Test external account insufficient funds (002):
# Create account with insufficient funds pattern
curl -X POST 'https://api.lightspark.com/grid/2025-10-13/customers/external-accounts' \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{
    "customerId": "Customer:sandbox001",
    "currency": "USD",
    "accountInfo": {
      "accountType": "US_ACCOUNT",
      "accountNumber": "000000002",
      "routingNumber": "021000021",
      "accountCategory": "CHECKING",
      "beneficiary": {
        "beneficiaryType": "INDIVIDUAL",
        "fullName": "Test User"
      }
    }
  }'

# Attempt off-ramp to this account - will fail immediately
curl -X POST 'https://api.lightspark.com/grid/2025-10-13/quotes/{quoteId}/execute' \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET"
# Response: 400 Bad Request with insufficient funds error
2. Test account closed (003):
# Create account with closed pattern
curl -X POST 'https://api.lightspark.com/grid/2025-10-13/customers/external-accounts' \
  -d '{"accountNumber": "000000003", ...}'

# Attempt to use - will fail with account closed error
3. Test insufficient balance in internal account:
# Create quote from empty internal account
curl -X POST 'https://api.lightspark.com/grid/2025-10-13/quotes' \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{
    "source": {
      "sourceType": "ACCOUNT",
      "accountId": "InternalAccount:empty_btc"
    },
    "destination": {
      "destinationType": "ACCOUNT",
      "accountId": "ExternalAccount:bank001"
    },
    "lockedCurrencySide": "SENDING",
    "lockedCurrencyAmount": 10000000
  }'

# Execute will fail with insufficient balance error
4. Test invalid wallet address:
# Attempt to create an external account with invalid Spark address
curl -X POST 'https://api.lightspark.com/grid/2025-10-13/customers/external-accounts' \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H 'Content-Type: application/json' \
  -d '{
    "currency": "BTC",
    "accountInfo": {
      "accountType": "SPARK_WALLET",
      "address": "invalid_address"
    }
  }'
# Response: 400 Bad Request with validation error

Global Account magic values

The Grid sandbox lets you exercise Global Account auth flows without moving real money. Email OTP uses the fixed sandbox code 000000. Passkey auth can use the same browser WebAuthn ceremony as production, and signed wallet actions can use the same decrypted session signing key and Grid-Wallet-Signature stamp as production. OAuth uses JWT-shaped sandbox OIDC tokens: sandbox skips real IdP signature verification, but still validates token claims, freshness, credential identity, and verify-time nonce binding. Sandbox-only compatibility values are still available for some flows, but they do not exercise the production-shaped client implementation. Authentication failures return 401 UNAUTHORIZED with a reason field that names the specific check that failed. A malformed OIDC JWT can return 400 INVALID_INPUT before authentication starts.

Email OTP code

Pass 000000 as the body otp on POST /auth/credentials/{id}/verify when the credential type is EMAIL_OTP. The sandbox skips OTP delivery and accepts this value as a valid response to the issued challenge.
curl -X POST https://api.lightspark.com/grid/2025-10-13/auth/credentials/AuthMethod:abc123/verify \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -H "Request-Id: 7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \
  -d '{
    "type": "EMAIL_OTP",
    "otp": "000000",
    "clientPublicKey": "04f45f2a..."
  }'
Any other code returns 401 UNAUTHORIZED with reason: "Invalid OTP code".

Passkey WebAuthn ceremony

For new sandbox integrations, use the same WebAuthn calls you plan to use in production.
1

Create a WebAuthn credential

Generate your own WebAuthn registration challenge and call navigator.credentials.create().
2

Register the passkey

Register the passkey with POST /auth/credentials, passing the challenge and attestation returned by the browser.
3

Request a challenge

Reauthenticate with POST /auth/credentials/{id}/challenge, passing the P-256 clientPublicKey that Grid should seal the session signing key to.
4

Run the browser assertion

Pass the returned challenge into navigator.credentials.get() using the returned credentialId in allowCredentials.
5

Verify the assertion

Verify with POST /auth/credentials/{id}/verify, passing the browser assertion and echoing Request-Id from the challenge response.
The sandbox validates the registered credential ID, WebAuthn challenge, origin/RP binding, user-presence bit, assertion signature, and signature counter. A successful verify response includes encryptedSessionSigningKey, sealed to the clientPublicKey, just like production.
# 1. /challenge with clientPublicKey
curl -X POST https://api.lightspark.com/grid/2025-10-13/auth/credentials/AuthMethod:abc123/challenge \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "clientPublicKey": "04f45f2a..."
  }'

# 2. /verify with the browser assertion returned by navigator.credentials.get()
curl -X POST https://api.lightspark.com/grid/2025-10-13/auth/credentials/AuthMethod:abc123/verify \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -H "Request-Id: 7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \
  -d '{
    "type": "PASSKEY",
    "assertion": {
      "credentialId": "...",
      "clientDataJson": "...",
      "authenticatorData": "...",
      "signature": "..."
    }
  }'
The legacy sandbox-only assertion signature sandbox-valid-passkey-signature is still accepted for compatibility, but it skips WebAuthn verification and should not be used for production-shaped sandbox tests.

OAuth (OIDC) token

OAuth does not use a fixed magic token in sandbox. Pass a JWT-shaped OIDC token as oidcToken. The JWT signature segment can be a dummy value, but the payload must look like a real ID token. For POST /auth/credentials with type: "OAUTH", the sandbox token must include:
  • iss: a supported issuer, such as https://accounts.google.com, accounts.google.com, or https://appleid.apple.com
  • aud: a non-empty string, or a single-element string array
  • sub: a non-empty subject identifier for the user
  • iat: a numeric issued-at timestamp no more than 60 seconds before the request, with 5 seconds of clock skew allowed
  • exp: a numeric expiration timestamp later than the request time
Grid stores the OAuth credential’s registered identity from iss, aud, and sub. On POST /auth/credentials/{id}/verify, the fresh oidcToken must carry the same iss, aud, and sub as the credential being verified. It must also include nonce equal to sha256(clientPublicKey), where clientPublicKey is the exact hex public key sent in the verify request.
export PUBLIC_KEY="04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2"
OIDC_TOKEN=$(node - <<'NODE'
const crypto = require("crypto");

const publicKey = process.env.PUBLIC_KEY || "04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2";
const now = Math.floor(Date.now() / 1000);
const b64url = (value) =>
  Buffer.from(JSON.stringify(value)).toString("base64url");

const payload = {
  iss: "https://accounts.google.com",
  sub: "sandbox-user-123",
  aud: "grid-sandbox-oauth-client-id",
  iat: now,
  exp: now + 300,
  nonce: crypto.createHash("sha256").update(publicKey).digest("hex"),
  email: "sandbox-user-123@example.com",
  email_verified: true
};

console.log(
  `${b64url({ alg: "RS256", typ: "JWT" })}.${b64url(payload)}.sandbox-signature`
);
NODE
)

curl -X POST https://api.lightspark.com/grid/2025-10-13/auth/credentials/AuthMethod:abc123/verify \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -H "Request-Id: 7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \
  -d '{
    "type": "OAUTH",
    "oidcToken": "'"$OIDC_TOKEN"'",
    "clientPublicKey": "'"$PUBLIC_KEY"'"
  }'
The old literal sandbox-valid-oidc-token is no longer accepted. Use a freshly generated sandbox JWT for both OAuth credential registration and OAuth verification. Production requires a real ID token from your provider and verifies the provider signature.

Wallet signature header

After verifying an auth credential, decrypt encryptedSessionSigningKey with the private key matching the clientPublicKey you supplied on verify or refresh. Use the decrypted session signing key to build a Turnkey API-key stamp over the exact payloadToSign string returned by Grid, then pass that full stamp as the Grid-Wallet-Signature HTTP header on signed flows:
  • POST /auth/credentials (add-additional-credential signed retry)
  • DELETE /auth/credentials/{id} (revoke credential)
  • DELETE /auth/sessions/{id} (revoke session)
  • POST /internal-accounts/{id}/export (export wallet)
  • PATCH /internal-accounts/{id} (update wallet privacy)
  • POST /quotes/{quoteId}/execute (when source is an embedded wallet)
This example uses the sample signer in the Grid API repo’s scripts directory. See the scripts README for setup, or replace SIGN with your own Turnkey API-key stamp implementation.
SIGN="node $(pwd)/scripts/embedded-wallet-sign.js"
STAMP=$($SIGN stamp "$SESSION_PRIV_HEX" "$PAYLOAD_TO_SIGN")

curl -X POST https://api.lightspark.com/grid/2025-10-13/quotes/Quote:abc123/execute \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \
  -H "Grid-Wallet-Signature: $STAMP"
Sandbox validates that the stamp is a P-256 Turnkey API-key stamp over the exact pending Turnkey payload and that the public key belongs to an active sandbox session for the wallet.
The legacy sandbox-only Grid-Wallet-Signature: sandbox-valid-signature value is still accepted for compatibility. Use a real session stamp when you want the client implementation to match production.

Moving to Production

When you’re ready to move to production:
  1. Generate production API tokens in the dashboard
  2. Swap those credentials for the sandbox credentials in your environment variables
  3. Remove any sandbox-specific test patterns from your code
  4. Configure production webhook endpoints
  5. Test with small amounts first

Next steps