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

The Grid sandbox environment allows you to test your integration without making real payments. When you set up your account, you can configure production and sandbox API tokens. The sandbox token is specifically for testing and development purposes.
It corresponds to a separate platform instance in "sandbox" mode, which can only transact with the sandbox UMA addresses for testing.

## Overview

The sandbox environment provides:

1. A dedicated sandbox platform for testing
2. Test UMA addresses for simulating payments
3. Endpoints to simulate sending and receiving payments
4. All the same webhooks and flows as production, but with simulated funds

## Test UMA Addresses

The sandbox provides several test UMA addresses you can use to simulate different scenarios:

| UMA Address                              | Description                        |
| ---------------------------------------- | ---------------------------------- |
| `$success.usd@sandbox.uma.money`         | Always succeeds, sends USD         |
| `$success.eur@sandbox.uma.money`         | Always succeeds, sends EUR         |
| `$success.mxn@sandbox.uma.money`         | Always succeeds, sends MXN         |
| `$pending.long.usd@sandbox.uma.money`    | Simulates a long-pending payment   |
| `$fail.compliance.usd@sandbox.uma.money` | Simulates compliance check failure |

## Testing Outgoing Payments

To test sending payments from your platform, follow these steps:

```mermaid theme={null}
sequenceDiagram
    participant Client as Your Platform
    participant Grid as Grid Sandbox
    participant Test as Test UMA Address

    Note over Client, Grid: Testing Outgoing Payments
    Client->>Grid: GET /receiver/uma/$success.usd@sandbox.uma.money
    Grid-->>Client: Supported currencies and requirements
    Client->>Grid: POST /quotes
    Grid-->>Client: Quote with payment instructions
    Client->>Grid: POST /sandbox/send
    Grid-->>Client: Payment simulated
    Grid->>Client: Webhook: OUTGOING_PAYMENT (COMPLETED)

    Note over Client, Grid: Testing Incoming Payments
    Client->>Grid: POST /sandbox/uma/receive
    Grid->>Client: Webhook: INCOMING_PAYMENT (PENDING)
    Client-->>Grid: HTTP 200 OK (approve payment)
    Grid->>Client: Webhook: INCOMING_PAYMENT (COMPLETED)
```

1. Look up a sandbox UMA address:

```bash theme={null}
curl -X GET "https://api.lightspark.com/grid/2025-10-13/receiver/uma/\$success.usd@sandbox.uma.money" \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET"
```

2. Create a quote as normal:

```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 '{
    "lookupId": "Lookup:019542f5-b3e7-1d02-0000-000000000009",
    "source": {
      "sourceType": "REALTIME_FUNDING",
      "currency": "MXN"
    },
    "destination": {
      "destinationType": "UMA_ADDRESS",
      "umaAddress": "$success.usd@sandbox.uma.money"
    },
    "lockedCurrencySide": "SENDING",
    "lockedCurrencyAmount": 10000
  }'
```

3. Instead of making a real bank transfer, use the sandbox send endpoint with the quote ID:

```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"
  }'
```

The sandbox will simulate the payment and send appropriate webhooks just like in production.

## Testing Incoming Payments

To test receiving payments to your platform's users, use the sandbox receive endpoint:

```bash theme={null}
curl -X POST "https://api.lightspark.com/grid/2025-10-13/sandbox/uma/receive" \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "senderUmaAddress": "$success.usd@sandbox.uma.money",
    "receiverUmaAddress": "$your.user@your.domain",
    "receivingCurrencyCode": "USD",
    "receivingCurrencyAmount": 5000
  }'
```

This will trigger the same webhook flow as a real incoming payment:

1. You'll receive an `INCOMING_PAYMENT` webhook with `status: "PENDING"`
2. Your platform should approve/reject the payment
3. On approval, you'll receive another webhook with `status: "COMPLETED"`

## Example Testing Flow

Here's a complete example of testing both directions of payments:

1. First, register 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_123",
    "customerType": "INDIVIDUAL",
    "fullName": "Test User",
    "birthDate": "1990-01-01",
    "nationality": "US",
    "address": {
      "line1": "123 Test St",
      "city": "Testville",
      "state": "TS",
      "postalCode": "12345",
      "country": "US"
    }
  }'
```

The response includes the customer's auto-generated `umaAddress`, which you can use as the `receiverUmaAddress` below.

2. Test receiving a payment:

```bash theme={null}
curl -X POST "https://api.lightspark.com/grid/2025-10-13/sandbox/uma/receive" \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{
    "senderUmaAddress": "$success.usd@sandbox.uma.money",
    "receiverUmaAddress": "$test.user@your.domain",
    "receivingCurrencyCode": "USD",
    "receivingCurrencyAmount": 5000
  }'
```

3. Test sending a payment:

```bash theme={null}
# 1. Look up recipient
curl -X GET "https://api.lightspark.com/grid/2025-10-13/receiver/uma/\$success.usd@sandbox.uma.money" \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET"

# 2. Create 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 '{
    "lookupId": "Lookup:019542f5-b3e7-1d02-0000-000000000009",
    "source": {
      "sourceType": "REALTIME_FUNDING",
      "currency": "MXN"
    },
    "destination": {
      "destinationType": "UMA_ADDRESS",
      "umaAddress": "$success.usd@sandbox.uma.money"
    },
    "lockedCurrencySide": "SENDING",
    "lockedCurrencyAmount": 10000
  }'

# 3. Simulate sending payment
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"
  }'
```

## Testing Error Scenarios

You can test various error scenarios using the special sandbox UMA addresses:

1. Test compliance failures:

```bash theme={null}
curl -X GET "https://api.lightspark.com/grid/2025-10-13/receiver/uma/\$fail.compliance.usd@sandbox.uma.money" \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET"
# ... create quote and attempt payment
```

2. Test long-pending payments:

```bash theme={null}
curl -X GET "https://api.lightspark.com/grid/2025-10-13/receiver/uma/\$pending.long.usd@sandbox.uma.money" \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET"
# ... create quote and attempt payment
```

3. Non-existent UMA address:

```bash theme={null}
curl -X GET "https://api.lightspark.com/grid/2025-10-13/receiver/uma/\$non.existent.usd@sandbox.uma.money" \
  -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET"
# ... should return 404 Not Found
```

Each of these will trigger appropriate error webhooks and status updates to help you test your error handling.

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

## Production vs Sandbox

Here are the key differences between production and sandbox environments:

1. **API Tokens**: Sandbox tokens only work in the sandbox environment and vice versa
2. **Bank Transfers**: In sandbox, you use `/sandbox/send` instead of real bank transfers
3. **Test UMA Addresses**: Special sandbox addresses for testing different scenarios
4. **Money**: No real money is moved in sandbox

Always test thoroughly in sandbox before moving to production!
