> ## Documentation Index
> Fetch the complete documentation index at: https://ramps-carson-verified-contact-change-models.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Sandbox Testing

> Test your payouts integration in the Grid sandbox environment

## Overview

The Grid sandbox environment allows you to test your payouts integration without moving real money. All API endpoints work the same way in sandbox as they do in production, but money movements 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 Internal Accounts

In production, internal accounts are funded by following the payment instructions (bank transfer, wire, etc.). In sandbox, you can instantly add funds to any internal account using the following endpoint:

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

{
  "amount": 100000  # $1,000 in cents
}
```

**Example:**

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

This endpoint returns the updated `InternalAccount` object with the new balance.

Alternatively, you can also fund internal accounts using the `/quotes` endpoint as described below.

## Testing Transfer Scenarios

### Adding Test External Accounts

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

Base58 addresses, such as Solana wallets, cannot contain `0`. For these, replace the leading `00` of a transfer or quote destination suffix with `11`: an address ending in `112` behaves like one ending in `002`, and `117` like `007`. This covers `112`–`118`. Either form works for any external account.

### Beneficiary name verification

For account types that support beneficiary name verification, you can simulate different verification outcomes in sandbox. Use account identifiers ending in `102`–`107` to trigger verification scenarios. These suffixes don't overlap the transfer and quote test patterns (`002`–`008`, or `112`–`118` for base58 addresses):

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

### Testing Transfer Outcomes

The external account's test pattern determines the outcome when that account is used:

These outcomes apply when the external account **funds an incoming transfer**
— money pulled from it into a Grid account. When the same account is the
**destination of a payout**, the quote suffix table applies instead, and several
suffixes mean different things there.

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

Create a quote with the external account on the side you want to test and
`immediatelyExecute` set to `true`:

```bash theme={null}
POST /quotes

{
  "source": {
    "sourceType": "ACCOUNT",
    "accountId": "InternalAccount:xyz789"
  },
  "destination": {
    "destinationType": "ACCOUNT",
    "accountId": "ExternalAccount:abc123"  // Uses test pattern from creation
  },
  "lockedCurrencySide": "SENDING",
  "lockedCurrencyAmount": 10000,  // $100 in cents
  "immediatelyExecute": true
}
```

Swap the two `accountId` values to pull from the external account instead. Either way the
transfer completes instantly in sandbox with the status the pattern dictates.

## Testing Cross-Currency Quotes

When creating a quote with an external account destination, the account number suffix determines the payment outcome after quote execution:

| Suffix | Behavior |
| - | - |
| **002** | Quote execution failed |
| **003** | Long payment — completes after approximately 6 minutes |
| **004** | Counterparty delivery failed |
| **005** | Receiving bank returned the payment — completes, then fails with `failureReason: PAYOUT_RETURNED` |
| **006** | User cancellation |
| **007** | Payout and refund failed |
| **008** | User cancellation while processing — the payment is refunded about a minute after execution, before it reaches the destination |
| **Any other** | Successful payment |

One suffix applies to the quote's **source** instead of its destination. Use it
on the external account a quote pulls funds from:

| Suffix | Behavior |
| - | - |
| **009** | Return after settlement — the pull settles and the transaction completes, then about 30 seconds later the originating bank returns it. The credited balance is taken back and the transaction moves to `FAILED`. Not simulated when the quote credits an Embedded Wallet |

Use **009** to test how your integration handles money arriving and then being
taken back. The deposit completes first, so any logic that treats a completed
incoming payment as final runs before the return arrives. You receive an
`INCOMING_PAYMENT.COMPLETED` webhook, then an `INCOMING_PAYMENT.FAILED` webhook
when the return lands.

### Creating Quotes with Test Accounts

When creating quotes with test external accounts, first create an external account with a test account pattern, then reference it in the quote:

```bash theme={null}
# Step 1: Create the external account with a test pattern
POST /customers/external-accounts

{
  "customerId": "Customer:123",
  "currency": "EUR",
  "accountInfo": {
    "accountType": "EUR_ACCOUNT",
    "iban": "DE89370400440532013003",
    "beneficiary": {
      "beneficiaryType": "INDIVIDUAL",
      "fullName": "Test User"
    }
  }
}

# Step 2: Create the quote using the external account ID
POST /quotes

{
  "source": {
    "sourceType": "ACCOUNT",
    "accountId": "InternalAccount:abc123"
  },
  "destination": {
    "destinationType": "ACCOUNT",
    "accountId": "ExternalAccount:..."
  },
  "lockedCurrencySide": "SENDING",
  "lockedCurrencyAmount": 100000
}
```

The IBAN ending in `003` triggers the slow payment test pattern.

### Executing Quotes in Sandbox

For quotes from an external account source, execute as in production via `/quotes/{quoteId}/execute`. The sandbox will:

1. Instantly process the currency conversion
2. Apply the test behavior based on any external accounts involved
3. Update transaction statuses immediately (no waiting for bank processing)
4. Trigger webhooks for state changes

For quotes with real-time funding (no source account), use the `/sandbox/send` endpoint to simulate the inbound payment. Reference the quote by ID and specify the funding currency:

```bash theme={null}
POST /sandbox/send

{
  "quoteId": "Quote:019542f5-b3e7-1d02-0000-000000000006",
  "currencyCode": "USD"
}
```

`currencyCode` must match the quote's funding-source currency. `currencyAmount` is optional — when omitted, the amount is derived from the quote.

## 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 (transfers, quote execution, 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 "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET"
```

## Common Testing Workflows

### Complete Payout Flow Test

Here's a complete test workflow for a USD → EUR payout:

1. **Create customer and internal accounts** (via regular API)

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

3. **Create a test external EUR account:**
   ```bash theme={null}
   POST /customers/external-accounts
   # Use default account number for success case
   ```

4. **Create and execute a quote:**
   ```bash theme={null}
   POST /quotes
   # USD internal → EUR external

   POST /quotes/{quoteId}/execute
   ```

5. **Verify transaction status and webhooks**

### Testing Error Scenarios

Test each failure mode systematically:

The suffix means different things depending on which side of the quote the
account sits on, so each scenario below says which it is.

```bash theme={null}
# 1. Pull from the account: insufficient funds
# Create external account ending in 002
POST /customers/external-accounts { "accountNumber": "000000002" }

# Fund a quote from it - the transfer fails immediately
POST /quotes

# 2. Push to the account: counterparty delivery fails
# Create external account ending in 004
POST /customers/external-accounts { "accountNumber": "000000004" }

# Pay out to it - the payout fails at the counterparty
POST /quotes

# 3. Push to the account: the receiving bank returns the payment
# Create external account ending in 005
POST /customers/external-accounts { "accountNumber": "000000005" }

# Pay out to it - the transaction completes, then fails with
# failureReason: PAYOUT_RETURNED
POST /quotes
GET /transactions/{transactionId}
```

## Global Account magic values

The Grid sandbox lets you exercise Global Account auth flows without moving real money. Email OTP and SMS OTP use 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 and SMS\_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 or SMS 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 and SMS OTP code

HPKE-encrypt the code `000000` (together with your TEK public key) inside `encryptedOtpBundle`. The sandbox skips email and SMS 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 the same for both `EMAIL_OTP` and `SMS_OTP`:

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}`. Use the compressed public key (`02` or `03` prefix) so the same code works in production.
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 Grid wallet signature over the exact `payloadToSign` string returned by Grid, then pass that full signature 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 Grid wallet-signature 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 signature is a P-256 signature over the exact pending Grid 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**: Most transfers resolve immediately. The delayed outcomes are the exceptions: a transfer suffix `005` pends about 30 seconds before failing, a quote suffix `003` takes about six minutes, a quote suffix `005` completes before the payout is returned, and a quote source suffix `009` completes and is returned about 30 seconds later
* **No real bank validation**: Account numbers aren't validated against real banking networks
* **Simplified KYC**: KYC processes are simulated rather than reviewed for real. On an unregulated platform the result resolves after a complete packet is submitted with `POST /verifications` — an incomplete one returns `RESOLVE_ERRORS` first. On a regulated platform, or with **Skip verification paperwork** on, an individual resolves at creation; a business never does — a terminal registration-number suffix resolves it on the first submission, while `001` and `003` still require a complete packet. Customers can be onboarded through `POST /customers` or the hosted link flow, as in production.
* **Fixed exchange rates**: Currency conversion rates may not reflect real-time market rates.

<Warning>
  Do not try sending money to any sandbox addresses or accounts. These are not real addresses and will not receive money.
</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
4. Configure production webhook endpoints
5. Test with small amounts first

## Next Steps

* Review [Webhooks](/payouts-and-b2b/platform-tools/webhooks) for event handling
* Check out the [Postman Collection](/payouts-and-b2b/platform-tools/postman-collection) for API examples
* See [Error Handling](/payouts-and-b2b/payment-flow/error-handling) for production error strategies


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.