> ## Documentation Index
> Fetch the complete documentation index at: https://docs.lightspark.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Sandbox Testing

> Test your rewards integration in the Grid sandbox environment

## Overview

The Grid sandbox environment allows you to test your rewards integration without moving real money or cryptocurrency. All API endpoints work the same way in sandbox as they do in production, but transactions are simulated and you can control test scenarios using special test values.

## Getting Started with Sandbox

### Sandbox Credentials

To use the sandbox environment:

1. Go to [app.lightspark.com](https://app.lightspark.com), create an account, and generate your sandbox API keys from the dashboard.
2. Add your sandbox API token and secret to your environment variables.
3. Use the normal production base URL: `https://api.lightspark.com/grid/2025-10-13`
4. Authenticate using your sandbox token with HTTP Basic Auth

## Simulating Money Movements

### Funding Platform Internal Accounts

In production, your platform's internal account is funded by following the payment instructions (bank transfer, wire, etc.). In sandbox, you can instantly add funds to your platform's internal account using the following endpoint:

```bash theme={null}
POST /sandbox/internal-accounts/{accountId}/fund

{
  "amount": 200000  # $2,000 in cents
}
```

**Example:**

```bash theme={null}
curl -X POST https://api.lightspark.com/grid/2025-10-13/sandbox/internal-accounts/InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965/fund \
  -u "sandbox_token_id:sandbox_token_secret" \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 200000
  }'
```

This endpoint returns the updated `InternalAccount` object with the new balance. You'll also receive an `INTERNAL_ACCOUNT.BALANCE_UPDATED` webhook showing the balance change.

<Note>
  In production, ACH transfers typically take 1-3 business days to settle. In sandbox, funding is instant.
</Note>

## Testing Reward Distributions

### Testing Successful Bitcoin Rewards

The standard reward flow works seamlessly in sandbox. First create the destination external account, then create and execute a quote to instantly convert USD to BTC:

```bash theme={null}
# Step 1: Create the destination external account
curl -X POST "https://api.lightspark.com/grid/2025-10-13/customers/external-accounts" \
  -u "sandbox_token_id:sandbox_token_secret" \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001",
    "currency": "BTC",
    "accountInfo": {
      "accountType": "SPARK_WALLET",
      "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu"
    }
  }'

# Step 2: Create and execute the quote
curl -X POST "https://api.lightspark.com/grid/2025-10-13/quotes" \
  -u "sandbox_token_id:sandbox_token_secret" \
  -H "Content-Type: application/json" \
  -d '{
    "source": {
      "sourceType": "ACCOUNT",
      "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965"
    },
    "destination": {
      "destinationType": "ACCOUNT",
      "accountId": "ExternalAccount:b23dcbd6-dced-4ec4-b756-3c3a9ea3d456"
    },
    "lockedCurrencySide": "SENDING",
    "lockedCurrencyAmount": 100,
    "immediatelyExecute": true,
    "description": "Bitcoin reward payout!"
  }'
```

In sandbox:

* The USD is instantly debited from your platform's internal account
* Bitcoin is "purchased" at a simulated exchange rate
* The Bitcoin is delivered to the Spark wallet address. In sandbox, BTC funds are regtest funds so that they're compatible with real regtest spark wallets.
* You receive an `OUTGOING_PAYMENT` webhook notification

<Info>
  In sandbox, Bitcoin transfers complete instantly on regtest. In production, Spark wallet transfers typically complete within seconds.
</Info>

### Testing Wallet Address Failures

Use special Spark wallet address patterns to test different failure scenarios. The **last 3 digits** of the wallet address determine the test behavior:

| Last Digits   | Behavior                | Use Case                                    |
| ------------- | ----------------------- | ------------------------------------------- |
| **003**       | Wallet unavailable      | Recipient wallet is offline or unreachable  |
| **005**       | Timeout/delayed failure | Transaction stays pending \~30s, then fails |
| **Any other** | Success                 | All transfers complete normally             |

**Example - Testing Wallet Unavailable:**

```bash theme={null}
# Create an external account with a test address ending in 003
curl -X POST "https://api.lightspark.com/grid/2025-10-13/customers/external-accounts" \
  -u "sandbox_token_id:sandbox_token_secret" \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001",
    "currency": "BTC",
    "accountInfo": {
      "accountType": "SPARK_WALLET",
      "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6m003"
    }
  }'

# Then use it in a quote
curl -X POST "https://api.lightspark.com/grid/2025-10-13/quotes" \
  -u "sandbox_token_id:sandbox_token_secret" \
  -H "Content-Type: application/json" \
  -d '{
    "source": {
      "sourceType": "ACCOUNT",
      "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965"
    },
    "destination": {
      "destinationType": "ACCOUNT",
      "accountId": "ExternalAccount:..."
    },
    "lockedCurrencySide": "SENDING",
    "lockedCurrencyAmount": 100,
    "immediatelyExecute": true
  }'
```

The quote execution will fail immediately with a wallet unavailable error.

Note that these failure test patterns work for any external account type. If you want to test other cases of funding from a broken fiat account,
you can create an external account with the appropriate test pattern and use that for the quote source for funding. There are also two other
failure test patterns relevant for bank accounts:

* **002**: Insufficient funds (transfer-in will fail)
* **004**: Transfer rejected (bank rejects the transfer)

## Testing Customer Onboarding

### Sandbox KYB Flow

In sandbox, the KYB onboarding process is simplified to always use the `/customers` endpoint instead of the KYB link flow.

```bash theme={null}
curl -X POST "https://api.lightspark.com/grid/2025-10-13/customers" \
  -u "sandbox_token_id:sandbox_token_secret" \
  -H "Content-Type: application/json" \
  -d '{
    "platformCustomerId": "user_12345",
    "customerType": "INDIVIDUAL",
    "fullName": "Jane Doe",
    "birthDate": "1992-03-25",
    "nationality": "US"
  }'
```

In sandbox, customers are automatically approved. In production, KYB verification may take several minutes.

## Testing Insufficient Balance

To test insufficient balance scenarios, simply attempt to send more than your platform's internal account balance:

```bash theme={null}
# Assuming account balance is $2,000 (200000 cents)
curl -X POST "https://api.lightspark.com/grid/2025-10-13/quotes" \
  -u "sandbox_token_id:sandbox_token_secret" \
  -H "Content-Type: application/json" \
  -d '{
    "source": {
      "sourceType": "ACCOUNT",
      "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965"
    },
    "destination": {
      "destinationType": "ACCOUNT",
      "accountId": "ExternalAccount:b23dcbd6-dced-4ec4-b756-3c3a9ea3d456"
    },
    "lockedCurrencySide": "SENDING",
    "lockedCurrencyAmount": 300000,
    "immediatelyExecute": true
  }'
```

The quote execution will fail with an insufficient balance error.

## Testing Webhooks

All webhook events fire normally in sandbox. To test your webhook endpoint:

1. Configure your webhook URL in the dashboard
2. Perform actions that trigger webhooks (funding accounts, executing quotes, etc.)
3. Receive webhook events at your endpoint
4. Verify signature using the sandbox public key

You can also manually trigger a test webhook:

```bash theme={null}
curl -X POST "https://api.lightspark.com/grid/2025-10-13/webhooks/test" \
  -u "sandbox_token_id:sandbox_token_secret" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/webhooks"
  }'
```

## Common Testing Workflows

### Complete Reward Distribution Test

Here's a complete test workflow for distributing a \$1.00 Bitcoin reward:

1. **Fund your platform's internal account:**
   ```bash theme={null}
   POST /sandbox/internal-accounts/InternalAccount:platform-usd/fund
   { "amount": 100000 }  # $1,000
   ```

2. **Create a test customer:**
   ```bash theme={null}
   POST /customers
   ```

3. **Execute a reward quote:**
   ```bash theme={null}
   POST /quotes
   # Platform USD account → Customer's Spark wallet
   # With immediatelyExecute: true
   ```

4. **Verify completion via webhook** (`OUTGOING_PAYMENT` event)

5. **Check transaction history:**
   ```bash theme={null}
   GET /transactions?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001
   ```

### Testing Error Scenarios

Test each failure mode systematically. First create external accounts with test address patterns, then use them in quotes:

```bash theme={null}
# 1. Test wallet unavailable (address ending in 003)
# Create external account with test address
curl -X POST "https://api.lightspark.com/grid/2025-10-13/customers/external-accounts" \
  -u "sandbox_token_id:sandbox_token_secret" \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "Customer:test001",
    "currency": "BTC",
    "accountInfo": {
      "accountType": "SPARK_WALLET",
      "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6m003"
    }
  }'
# Use the returned ExternalAccount ID in the quote
curl -X POST "https://api.lightspark.com/grid/2025-10-13/quotes" \
  -u "sandbox_token_id:sandbox_token_secret" \
  -H "Content-Type: application/json" \
  -d '{
    "source": {"sourceType": "ACCOUNT", "accountId": "InternalAccount:platform-usd"},
    "destination": {"destinationType": "ACCOUNT", "accountId": "ExternalAccount:..."},
    "lockedCurrencySide": "SENDING",
    "lockedCurrencyAmount": 100,
    "immediatelyExecute": true
  }'
# Response: Transaction fails with wallet unavailable error

# 2. Test insufficient balance (use any valid external account)
curl -X POST "https://api.lightspark.com/grid/2025-10-13/quotes" \
  -u "sandbox_token_id:sandbox_token_secret" \
  -H "Content-Type: application/json" \
  -d '{
    "source": {"sourceType": "ACCOUNT", "accountId": "InternalAccount:platform-usd"},
    "destination": {"destinationType": "ACCOUNT", "accountId": "ExternalAccount:..."},
    "lockedCurrencySide": "SENDING",
    "lockedCurrencyAmount": 10000000,
    "immediatelyExecute": true
  }'
# Response: 400 Bad Request with insufficient balance error

# 3. Test timeout scenario (address ending in 005)
# Create external account with test address
curl -X POST "https://api.lightspark.com/grid/2025-10-13/customers/external-accounts" \
  -u "sandbox_token_id:sandbox_token_secret" \
  -H "Content-Type: application/json" \
  -d '{
    "customerId": "Customer:test001",
    "currency": "BTC",
    "accountInfo": {
      "accountType": "SPARK_WALLET",
      "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6m005"
    }
  }'
# Use the returned ExternalAccount ID in the quote
curl -X POST "https://api.lightspark.com/grid/2025-10-13/quotes" \
  -u "sandbox_token_id:sandbox_token_secret" \
  -H "Content-Type: application/json" \
  -d '{
    "source": {"sourceType": "ACCOUNT", "accountId": "InternalAccount:platform-usd"},
    "destination": {"destinationType": "ACCOUNT", "accountId": "ExternalAccount:..."},
    "lockedCurrencySide": "SENDING",
    "lockedCurrencyAmount": 100,
    "immediatelyExecute": true
  }'
# Check status immediately - will show PENDING
# Wait 30s, check again - will show FAILED
```

## 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` — HPKE-encrypt that code in the `encryptedOtpBundle` just like production. Passkey auth can use the same browser WebAuthn ceremony as production, and signed wallet actions can use the same 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 runs real HPKE end-to-end for EMAIL\_OTP: clients build a real `encryptedOtpBundle` against the sandbox `otpEncryptionTargetBundle` and sign a real `verificationToken` with their TEK keypair. The only sandbox shortcut is the magic OTP code the user "receives" instead of a real email delivery.

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

HPKE-encrypt the code `000000` (together with your TEK public key) inside `encryptedOtpBundle`. The sandbox skips email delivery but runs real HPKE decryption and signature verification.

See <a href="/global-accounts/integration-guides/client-keys#encrypt-the-otp-code-email_otp-only">Encrypt the OTP code</a> for how to build the bundle. The flow is:

1. Call `POST /auth/credentials/{id}/challenge` to get `otpEncryptionTargetBundle`
2. Generate a TEK key pair and HPKE-encrypt `{otp_code: "000000", public_key: tekPublicKeyHex}`
3. Submit `encryptedOtpBundle` to `POST /auth/credentials/{id}/verify`
4. Receive `202` with `payloadToSign` and `requestId`
5. Sign `payloadToSign` with the TEK private key and retry with `Grid-Wallet-Signature` + `Request-Id` headers

```bash theme={null}
# First leg — returns 202 with payloadToSign
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" \
  -d '{
    "type": "EMAIL_OTP",
    "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}"
  }'

# Signed retry — returns 200 with AuthSession
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 "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4i..." \
  -H "Request-Id: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \
  -d '{
    "type": "EMAIL_OTP",
    "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}"
  }'
```

Any other code (once decrypted) 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.

<Steps>
  <Step title="Create a WebAuthn credential">
    Generate your own WebAuthn registration challenge and call `navigator.credentials.create()`.
  </Step>

  <Step title="Register the passkey">
    Register the passkey with `POST /auth/credentials`, passing the challenge and attestation returned by the browser.
  </Step>

  <Step title="Request a challenge">
    Reauthenticate with `POST /auth/credentials/{id}/challenge`, passing the P-256 `clientPublicKey` that Grid should seal the session signing key to.
  </Step>

  <Step title="Run the browser assertion">
    Pass the returned `challenge` into `navigator.credentials.get()` using the returned `credentialId` in `allowCredentials`.
  </Step>

  <Step title="Verify the assertion">
    Verify with `POST /auth/credentials/{id}/verify`, passing the browser assertion and echoing `Request-Id` from the challenge response.
  </Step>
</Steps>

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.

```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 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: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \
  -d '{
    "type": "PASSKEY",
    "assertion": {
      "credentialId": "...",
      "clientDataJson": "...",
      "authenticatorData": "...",
      "signature": "..."
    }
  }'
```

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

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

For `PASSKEY` and `OAUTH` credentials, decrypt `encryptedSessionSigningKey` with the private key matching the `clientPublicKey` you supplied on verify or refresh. For `EMAIL_OTP`, the TEK private key you generated for the encrypted OTP flow **is** the session signing key — no decryption step needed. Use the 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)

<Note>
  This example uses the sample signer in the Grid API repo's [scripts directory](https://github.com/lightsparkdev/grid-api/tree/main/scripts). See the [scripts README](https://github.com/lightsparkdev/grid-api/blob/main/scripts/README.md) for setup, or replace `SIGN` with your own Turnkey API-key stamp implementation.
</Note>

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

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

## Sandbox Limitations

While sandbox closely mimics production, there are some differences:

* **Instant settlement**: All Bitcoin transfers complete instantly (success cases) or fail immediately (error cases), except timeout scenarios (005)
* **Uses Regtest funds**: Spark bitcoin funds are regtest funds so that they're compatible with real regtest spark wallets.
* **Simplified KYB**: KYB processes are simulated and complete instantly with automatic approval
* **Fixed exchange rates**: Currency conversion rates may not reflect real-time market rates

<Warning>
  Do not try sending money to any sandbox wallet addresses or bank accounts. These are not real addresses and will not receive funds.
</Warning>

## 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 (magic number wallet addresses)
4. Configure production webhook endpoints
5. Test with small reward amounts first ($0.01-$1.00)
6. Gradually increase volume as you gain confidence

## Next Steps

* Review [Webhooks](/rewards/platform-tools/webhooks) for event handling
* Check out the [Postman Collection](/rewards/platform-tools/postman-collection) for API examples
* See [Platform Configuration](/rewards/developer-guides/platform-configuration) for production settings
