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

# Sandbox Testing

> Use Grid sandbox magic values to trigger specific API responses

The Grid sandbox environment simulates real payment flows without moving real money. You can control test outcomes using special account number patterns and test addresses.

## KYC/KYB verification

In sandbox, you can trigger specific KYC/KYB verification outcomes using magic suffixes in customer and beneficial owner fields. These let you test different verification flows without waiting for real review.

### Business customer verification

The **last 3 digits** of the `registrationNumber` in `businessInfo` determine the KYB status outcome when you call `POST /verifications`:

| Suffix        | `kybStatus` | Behavior                         |
| ------------- | ----------- | -------------------------------- |
| **001**       | `PENDING`   | KYB verification remains pending |
| **002**       | `REJECTED`  | KYB verification is rejected     |
| **Any other** | `APPROVED`  | KYB verification is approved     |

### Beneficial owner KYC

The **last 3 characters** of the `lastName` in `personalInfo` determine the individual KYC status outcome:

| Suffix        | `kycStatus` | Behavior                         |
| ------------- | ----------- | -------------------------------- |
| **001**       | `PENDING`   | KYC verification remains pending |
| **002**       | `REJECTED`  | KYC verification is rejected     |
| **Any other** | `APPROVED`  | KYC verification is approved     |

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

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

## Transfer in

In production, internal accounts are funded by sending a bank transfer to the account's payment instructions or by pulling from an external account. In sandbox, you have two options:

### Transfer in from an external account

Use the `/transfer-in` endpoint to pull funds from an external account into an internal account. The external account's number suffix determines the outcome:

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

### Sandbox fund endpoint

Instantly add funds to any internal account using `/sandbox/internal-accounts/{accountId}/fund`:

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

## Creating quotes (cross-currency transfers)

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 payment (completes then transitions to failed) |
| **006**       | User cancellation                                                      |
| **007**       | Payout and refund failed                                               |
| **Any other** | Successful payment                                                     |

### Executing a quote

After creating a quote, you need to fund it to trigger execution. There are two ways to do this in sandbox:

**Prefunded internal account** — If your quote's source is an internal account, fund the account using one of the methods described in [transfer in](#transfer-in), then call the quote execute endpoint to trigger the transaction:

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

**Real-time funding via sandbox send** — If your quote uses real-time funding, the quote response includes payment instructions for you to transfer funds to. Use `/sandbox/send` to simulate this payment:

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

## Transferring out funds

Use the `/transfer-out` endpoint to push funds from an internal account to an external account in the same currency. The external account's number suffix controls the outcome using the same patterns as [transfer in](#transfer-in-from-an-external-account).

## Sending to a UMA address

For UMA-based payments, use these sandbox addresses to simulate different scenarios:

| UMA Address                              | Behavior                             |
| ---------------------------------------- | ------------------------------------ |
| `$success.usd@sandbox.uma.money`         | Payment succeeds (USD)               |
| `$success.eur@sandbox.uma.money`         | Payment succeeds (EUR)               |
| `$success.mxn@sandbox.uma.money`         | Payment succeeds (MXN)               |
| `$pending.long.usd@sandbox.uma.money`    | Simulates a long-pending payment     |
| `$fail.compliance.usd@sandbox.uma.money` | Simulates a compliance check failure |

### Simulating incoming UMA payments

Use the sandbox receive endpoint to simulate an incoming UMA payment to one of your platform's users:

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

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