> ## Documentation Index
> Fetch the complete documentation index at: https://ramps-sync-country-coverage-2026-05-29.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Sandbox Testing

> Test ramp flows safely without moving real funds

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

<Info>
  Sandbox is perfect for development, testing, and demonstrating ramp
  functionality before going live.
</Info>

## Getting started

### Create sandbox credentials

1. Log into the Grid dashboard
2. Navigate to **Settings** → **API Keys**
3. Click **Create API Key** and select **Sandbox** environment
4. Save your API key ID and secret securely

<Warning>
  Sandbox credentials only work with the sandbox environment. They cannot access
  production data or move real funds.
</Warning>

### Configure sandbox webhook

Set up a webhook endpoint for sandbox notifications:

```bash theme={null}
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"
  }'
```

<Tip>
  Use tools like ngrok to expose local webhook endpoints during development:
  `ngrok http 3000`
</Tip>

## Testing on-ramps (Fiat → Crypto)

Simulate the complete on-ramp flow in sandbox:

### Step 1: Create a test customer

```bash theme={null}
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"
    }
  }'
```

<Check>In sandbox, customers are automatically approved for testing.</Check>

### Step 2: Create an external account for the destination wallet

```bash theme={null}
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)

```bash theme={null}
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:

```bash theme={null}
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"
  }'
```

<Note>
  `currencyCode` must match the funding-source currency on the quote.
  `currencyAmount` is optional — when omitted, the amount is derived from the
  quote.
</Note>

### Step 5: Verify completion

Within seconds, you'll receive a webhook notification confirming the on-ramp completed:

```json theme={null}
{
  "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:

```bash theme={null}
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.

<Check>
  You'll receive an `INTERNAL_ACCOUNT.BALANCE_UPDATED` webhook showing the updated balance.
</Check>

### Step 2: Create external bank account

```bash theme={null}
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"
        }
      }
    }
  }'
```

<Note>
  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.
</Note>

### Step 3: Create and execute off-ramp quote

```bash theme={null}
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:

```bash theme={null}
curl -X POST 'https://api.lightspark.com/grid/2025-10-13/quotes/{quoteId}/execute' \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET"
```

<Info>
  In sandbox, off-ramp conversions complete instantly. In production, bank
  settlement may take 1-3 business days.
</Info>

## 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`.

| Suffix        | Behavior                                                  |
| ------------- | --------------------------------------------------------- |
| **002**       | Insufficient funds — transfer fails immediately           |
| **003**       | Account closed/invalid — transfer fails immediately       |
| **004**       | Transfer rejected — bank rejects the transfer             |
| **005**       | Timeout/delayed failure — stays pending \~30s, then fails |
| **Any other** | Success — 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):

| Suffix        | `beneficiaryVerificationStatus` | Behavior                                                             |
| ------------- | ------------------------------- | -------------------------------------------------------------------- |
| **102**       | `NOT_MATCHED`                   | Account is valid but name does not match                             |
| **103**       | `PARTIAL_MATCH`                 | Account is valid, name is a fuzzy match                              |
| **104**       | `PENDING`                       | Verification still in progress                                       |
| **105**       | *(error)*                       | Returns `400` — invalid account                                      |
| **106**       | `UNSUPPORTED`                   | Payment rail does not support name verification                      |
| **107**       | `CHECKED_BY_RECEIVING_FI`       | Verification deferred to receiving financial institution (e.g., ACH) |
| **Any other** | `MATCHED`                       | Account 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):**

```bash theme={null}
# 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):**

```bash theme={null}
# 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:**

```bash theme={null}
# 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:**

```bash theme={null}
# 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 accepts a small set of magic values for Global Account flows, so you can exercise the full request shape without standing up Turnkey, WebAuthn, or an OIDC provider. OTP, passkey, and wallet signatures use fixed sandbox-only values. OAuth uses JWT-shaped sandbox OIDC tokens: sandbox skips real IdP signature verification, but still validates the token claims, freshness, credential identity, and verify-time nonce binding.

A wrong magic value or sandbox OIDC authentication failure returns `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.

```bash theme={null}
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 assertion signature

Pass `sandbox-valid-passkey-signature` as `assertion.signature` on `POST /auth/credentials/{id}/verify` when the credential type is `PASSKEY`. The sandbox accepts the rest of the assertion as-is and skips the WebAuthn signature check.

Passkey reauthentication is a two-step `/challenge` → `/verify` flow. The `clientPublicKey` is sent on `/challenge` (so Grid can seal the session signing key to your device) — the magic value bypasses the credential check, not the HPKE plumbing, so the public key is still required.

```bash theme={null}
# 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 magic signature, no clientPublicKey
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": "sandbox-valid-passkey-signature"
    }
  }'
```

Any other signature returns `401 UNAUTHORIZED` with `reason: "Invalid passkey signature"`.

### 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.

```bash theme={null}
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"'"
  }'
```

<Note>
  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.
</Note>

### Wallet signature header

Pass `sandbox-valid-signature` as the `Grid-Wallet-Signature` HTTP header on any signed-retry flow:

* `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)

```bash theme={null}
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: sandbox-valid-signature"
```

Any other header value returns `401 UNAUTHORIZED` with `reason: "Invalid Grid-Wallet-Signature"`.

## 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

* [Webhooks](/ramps/platform-tools/webhooks) - Handle real-time notifications
* [Fiat-to-Crypto Conversion](/ramps/conversion-flows/fiat-crypto-conversion) - Implement production flows
* [Self-Custody Wallets](/ramps/conversion-flows/self-custody-wallets) - Advanced wallet integration
* [Platform Configuration](/ramps/onboarding/platform-configuration) - Configure production settings
* [API Reference](/api-reference) - Complete API documentation
