# Add Lightspark to your AI app Source: https://agents-docs.lightspark.com/agent-accounts/ai-app-connector List Lightspark as a connector in your AI app so your users can open an account, get paid, pay people and run a business through your assistant, with no payments backend of your own. > [!NOTE] > > The Lightspark connector is in limited availability. To list it in your app, [book a demo](https://www.lightspark.com/contact) or reach out to your Lightspark contact. Lightspark runs an MCP server your app can add as a connector. Your users connect their Lightspark account to your assistant through OAuth, and the assistant can then check balances, add money, pay people, use cards and, for a business, send invoices and manage team budgets, all within limits the user agrees to. Lightspark handles sign-up, identity checks, approvals and the account itself. You do not need a Grid platform or a backend of your own. ## How a user connects 1. The user picks Lightspark in your app's connector list. 2. Your app starts a standard MCP OAuth flow. Lightspark supports dynamic client registration, or can issue your app its own client. 3. The user signs in or signs up with Lightspark, for example with a one-time code sent to their email, and accepts the terms. Lightspark opens their account. 4. The user sees what your app is asking for, read-only or full access and the spending limits that come with it, and approves. For full access, their browser authorizes Lightspark to sign the assistant's payments within those limits. 5. Your app receives its token, and the assistant can call Lightspark. ## Approvals stay with the user Payments above the user's approval threshold wait for them. Lightspark sends them an email or text, and they approve or decline on Lightspark's page. The user can pause or disconnect your app at any time from their Lightspark account. ## Getting paid Once verified, a user gets a US bank account and routing number that anyone can pay by bank transfer, alongside crypto and Lightning. A business can also send a client an invoice: the client gets a payment page, and the payment is matched to the invoice when it arrives. ## For businesses A user can set up a business account through the assistant, complete business verification, invite team members, and give each member's assistant its own budget on the business's shared balance. Admins can be required to approve team payments. ## What your app does * Add the connector and pass the OAuth flow through to the user. * Show the assistant's results. Where your app renders MCP Apps, Lightspark returns cards for things like invoices. Everything else, including identity verification, approvals, notifications and the account itself, runs on Lightspark. ## Building your own account experience instead? If you are a Grid platform and want agents to act on accounts your customers already hold with you, with approvals in your own app, use the [Agents API](/agent-accounts/implementation-overview). # Approvals & audit Source: https://agents-docs.lightspark.com/agent-accounts/approvals-and-audit Show the customer what an agent wants to do, send their decision to Grid, and keep a history of every agent action. > [!NOTE] > > The Agents API must be enabled for your platform; until it is, agent endpoints answer `403`. To enable it, [book a demo](https://www.lightspark.com/contact) or reach out to your Lightspark contact. Every money movement an agent asks for is an `AgentAction`. Grid creates it, decides whether it can run on its own, and when it can't, holds it in `PENDING_APPROVAL` until your platform sends the customer's decision. Grid owns the approval state and the execution; your product owns how the request reaches the customer. ## Approval lifecycle 1. The agent submits a request: executing a quote, issuing a purchase card, using a stored card, sending a claimable payout, starting a payout run, or proposing a settlement rule. 2. Grid checks the agent's policy and spending pool. A request they forbid is refused outright. One they allow either runs at once (`APPROVED`) or waits (`PENDING_APPROVAL`). 3. For a waiting request, Grid sends `AGENT_ACTION.PENDING_APPROVAL` to your [webhook endpoint](/agent-accounts/platform-tools/webhooks) with the full `AgentAction`, so you can render the request without a second API call. 4. You notify the customer and show the request in a surface they trust. 5. Your backend calls `POST /agents/{agentId}/actions/{actionId}/approve` or `.../reject` with platform credentials. 6. Grid re-checks the request against the current policy and executes it. The action ends `APPROVED`, `REJECTED` or `FAILED`. 7. The agent learns the outcome by polling `GET /agents/me/actions/{actionId}`. ```mermaid theme={null} stateDiagram-v2 [*] --> APPROVED: within policy [*] --> PENDING_APPROVAL: needs the customer PENDING_APPROVAL --> APPROVED: approve PENDING_APPROVAL --> REJECTED: reject PENDING_APPROVAL --> FAILED: expired, or no longer allowed APPROVED --> [*] REJECTED --> [*] FAILED --> [*] ``` ## Action types | `type` | The agent asked to | Shown in | Approving it | | - | - | - | - | | `EXECUTE_QUOTE` | Send money on a quote | `quote` | Executes the quote | | `ISSUE_PURCHASE_CARD` | Get a purchase card, or raise an unused one's amount | `purchaseCard` | Issues the card (or raises it) and sets `cardId` | | `USE_STORED_CARD` | Use one of the customer's stored cards | `storedCardUse` | Lets the agent collect one single-use link to the card's details | | `CLAIMABLE_PAYOUT` | Pay a person by phone number or email | `claimablePayout` | Makes the payout claimable (`AWAITING_CLAIM`) | | `PAYOUT_RUN` | Pay up to 50 people at once | `payoutRun` | Starts the run; the approval covers every row | | `CREATE_SETTLEMENT_RULE` | Split future incoming payments by a standing rule | `settlementRule` | Makes the rule `ACTIVE` | | `UPDATE_SETTLEMENT_RULE` | Change a rule's match, shares or limits | `settlementRule` (as changed) | Applies the change | Approving a settlement rule does not approve its payments in advance: each share is still checked against the agent's policy when it is sent. ## What to show the customer The approval UI should answer, at a glance: * Which agent is asking, and for what (`type`)? * How much, in which currencies, and from which account? * To whom or where? For a purchase card or stored card use, the merchant and the agent's description of the purchase. * Why does it need approval, and until when (`expiresAt`)? Values the agent supplies, such as a purchase card's `description` or a stored card use's `merchant` and `purpose`, are not verified by Grid. Present them as the agent's words. ### A pending send ```json theme={null} { "id": "AgentAction:019542f5-b3e7-1d02-0000-000000000099", "agentId": "Agent:019542f5-b3e7-1d02-0000-000000000042", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000010", "platformCustomerId": "user-a1b2c3", "status": "PENDING_APPROVAL", "type": "EXECUTE_QUOTE", "quote": { "id": "Quote:019542f5-b3e7-1d02-0000-000000000006", "status": "PENDING", "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, "destinationSparkAddress": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu", "sendingCurrency": { "code": "USDB", "name": "USDB", "symbol": "$", "decimals": 2 }, "receivingCurrency": { "code": "USDB", "name": "USDB", "symbol": "$", "decimals": 2 }, "totalSendingAmount": 5000, "totalReceivingAmount": 5000, "expiresAt": "2025-10-03T15:00:30Z" }, "expiresAt": "2025-10-03T15:00:30Z", "createdAt": "2025-10-03T15:00:00Z", "updatedAt": "2025-10-03T15:00:00Z" } ``` ### A pending purchase card ```json theme={null} { "id": "AgentAction:019542f5-b3e7-1d02-0000-000000000098", "agentId": "Agent:019542f5-b3e7-1d02-0000-000000000042", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000010", "platformCustomerId": "user-a1b2c3", "status": "PENDING_APPROVAL", "type": "ISSUE_PURCHASE_CARD", "purchaseCard": { "amount": 12000, "spendLimit": 13000, "currency": "USD", "description": "Concert tickets from example-tickets.com", "fundingSource": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" }, "expiresAt": "2026-10-26T15:15:00Z", "createdAt": "2026-10-26T15:00:00Z", "updatedAt": "2026-10-26T15:00:00Z" } ``` ## Approving and rejecting Both calls use your platform credentials and return the updated `AgentAction`. The `agentId` and `actionId` are in the webhook payload. ```bash theme={null} # Approve curl -X POST "$GRID_BASE_URL/agents/Agent:019542f5-b3e7-1d02-0000-000000000042/actions/AgentAction:019542f5-b3e7-1d02-0000-000000000099/approve" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" # Reject, with an optional reason stored on the action curl -X POST "$GRID_BASE_URL/agents/Agent:019542f5-b3e7-1d02-0000-000000000042/actions/AgentAction:019542f5-b3e7-1d02-0000-000000000099/reject" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "reason": "Customer declined in app" }' ``` Only a `PENDING_APPROVAL` action can be decided; deciding one that is already decided returns `409`. Treat a `409` on a retry as "already handled" and read the action's current state. > [!WARNING] > > Grid cannot see the customer when you call approve. Confirm the decision with them in your app first, with a fresh step-up such as a passkey or an emailed code, especially for stored card uses and anything above your own risk thresholds. ### Approving a stored card use `USE_STORED_CARD` is the only type that takes a body on approve: | Field | Use | | - | - | | `storedCardCvc` | The card's security code, encrypted by Evervault's Card component (`ev:...`). Required when `storedCardUse.needsCvc` is `true`; approving such a use without it is refused with `400`. Grid holds it only until the agent opens its link or the link expires. | | `autoApproveMerchant` | Also let this agent use this card at this merchant again, up to this use's amount, without asking. A later use that needs the security code still asks. | Sending either field on any other action type is refused with `400`. See [Cards](/agent-accounts/cards#stored-cards). ## When approval doesn't execute Grid checks the action again when you approve it, against the account and policy as they are then. Between the request and the decision the agent may have been paused, the limits lowered, the daily spend used up, or the quote expired. In those cases the action ends `FAILED` with a `failureReason`: | `failureReason` | Meaning | | - | - | | `QUOTE_EXPIRED` | The quote expired before execution, or Grid could no longer quote the send | | `POLICY_DENIED` | The agent's policy no longer allows the action | | `AGENT_INACTIVE` | The agent was paused or deleted, or, for a stored card use, the card was removed or the agent lost access | | `EXECUTION_FAILED` | Grid could not execute the approved action, for example the card issuer did not issue a purchase card | | `ADDRESS_REFUSED` | Grid refused the send's destination before signing, for example a Spark address removed from the customer's saved addresses | | `APPROVAL_EXPIRED` | The action was not approved before its `expiresAt` | | `DELETED_BY_AGENT` | The agent deleted the settlement rule the action would create or change | | `AGENT_CANCELLED` | The agent cancelled the payout run the action would start | `PRICE_MOVED` is reserved and legacy; quotes to stablecoin addresses pay at a locked rate and no longer fail with it. ### Expiry `expiresAt` on the action is when it stops waiting: * An `EXECUTE_QUOTE` action without `priceTolerance` expires with its quote. Quotes are short-lived, so notify the customer immediately. * An `EXECUTE_QUOTE` action with `priceTolerance`, and every `ISSUE_PURCHASE_CARD`, `USE_STORED_CARD`, `CLAIMABLE_PAYOUT`, `PAYOUT_RUN`, `CREATE_SETTLEMENT_RULE` and `UPDATE_SETTLEMENT_RULE` action, waits until the end of its own approval window. A payout run's window is 24 hours and a settlement rule's seven days. ### Re-quoted sends Where Grid has enabled it for your platform, an `EXECUTE_QUOTE` action carries `priceTolerance` and keeps waiting after its quote expires: * Approved while the quote is live, it sends that quote as shown. * Approved after the quote expired, Grid quotes the send again and runs every check against the new quote. If the new price is no worse for the customer than `priceTolerance` allows (the larger of `basisPoints` of the amount shown and `minimumAmount`), Grid sends it and the action is `APPROVED` with the new `quote`. * If the price moved against the customer by more, nothing is sent. Approve returns `200` with the action still `PENDING_APPROVAL`, the new `quote`, and `previousQuote` set to the quote the customer saw. Show both and ask again; approving again repeats the check. A better price never asks again. Tell the customer about the tolerance when you ask them, using the numbers in `priceTolerance`. ## History and audit | You want | Call | | - | - | | Actions awaiting or requiring approval, across agents | `GET /agents/approvals`, filtered by `agentId`, `customerId`, `startDate` and `endDate`; `limit` (default 20, max 100), `cursor`, `sortOrder` | | One agent's own actions (agent token) | `GET /agents/me/actions`, filtered by `status` | | Money that moved | `GET /agents/me/transactions` (agent token), or your usual transaction endpoints and webhooks. An approved action carries its `transaction` once execution begins. | `rejectionReason` is set only on `REJECTED` actions and `failureReason` only on `FAILED` ones, so the customer can always tell a decline from a failure. Grid also records an audit entry with the agent as the actor each time an agent reveals a purchase card or a stored card, or adds a card to a digital wallet. ## Pause, revoke and delete Customers need immediate control over delegated access. Offer at least: * **Pause:** `PATCH /agents/{agentId}` with `isPaused: true`. A paused agent cannot initiate any action; its pending actions fail with `AGENT_INACTIVE` if approved. Resume with `isPaused: false`. * **Revoke signing:** `DELETE /auth/delegated-keys/{id}` stops Grid signing the agent's sends from that wallet. * **Delete:** `DELETE /agents/{agentId}`. Connected agent software loses access immediately. ## Integration guidance * Subscribe to `AGENT_ACTION.PENDING_APPROVAL` and send the customer a push notification at once. * Build an in-app approval queue from `GET /agents/approvals` for customers who miss the notification. * Make approve and reject safe to retry: a duplicate tap should land on "already handled", not an error. * Show `REJECTED` and `FAILED` differently. They mean different things to the customer. * Keep agent activity next to the account and transaction views the customer already uses. # Cards Source: https://agents-docs.lightspark.com/agent-accounts/cards Give agents their own virtual cards, single-use purchase cards for one purchase, recurring cards locked to one merchant, and bounded access to cards the customer already owns. > [!NOTE] > > The Agents API must be enabled for your platform, and agent cards, purchase cards, recurring purchase cards and stored cards are each enabled per platform on top of it; until they are, their endpoints answer `403`. To enable them, [book a demo](https://www.lightspark.com/contact) or reach out to your Lightspark contact. Most merchants an agent buys from take cards. Grid gives you four ways to let an agent pay by card, from the most bounded to the most flexible: | Card | What it is | Who holds the number | | - | - | - | | **Single-use purchase card** | A virtual card for one online purchase of a set amount, paid from the customer's Global Account. Closes after its first approved charge. | The agent, through a short-lived reveal link | | **Recurring purchase card** | A virtual card locked to the first merchant that charges it, up to a monthly amount. Always needs the customer's approval. | The agent, through a short-lived reveal link | | **Standard agent card** | A virtual card issued for the agent, spending from the customer's account under the agent's policy | The customer | | **Stored card** | A card the customer already owns, stored encrypted, which the agent may use one approved purchase at a time | Grid holds it only encrypted; the agent gets it through a single-use link | Authorizations on cards Grid issues for an agent count against the agent's [spending limits](/agent-accounts/policies-and-permissions#spending-limits) and [spending pool](/agent-accounts/spending-limits), and Grid's default merchant-category blocks for agent cards apply on top of any you set. Stored cards are different: the merchant charges the customer's own card, so Grid cannot enforce an amount on them. ## Prerequisites * The customer's KYC status (KYB for a business) is `APPROVED`, and they have accepted the cardholder agreement if your platform requires one. Otherwise issuing fails with `CARDHOLDER_KYC_NOT_APPROVED` or `CARDHOLDER_AGREEMENT_NOT_ACCEPTED`. * The agent's policy grants `ISSUE_CARDS` to issue and `MANAGE_CARDS` to read, tighten, freeze and reveal. * For purchase cards, the funding source is an Embedded Wallet on which the agent holds an `ACTIVE` [delegated key](/agent-accounts/creating-agents#delegate-signing-on-the-global-account), and the agent's policy is in USD. ## Purchase cards A purchase card is the standard way for an agent to pay a merchant: the agent asks for a card scoped to one order, the customer approves it if the policy says so, and the agent reads the number at checkout. ### Request a single-use card ```bash theme={null} curl -X POST "$GRID_BASE_URL/agents/me/cards" \ -H "Authorization: Bearer $AGENT_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \ -d '{ "kind": "PURCHASE", "fundingSource": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "amount": 4999, "description": "Running shoes from example-store.com" }' ``` `amount` is in USD cents. Grid sets the card's issuer-enforced limit, `purchase.spendLimit`, to `amount` plus an allowance for tax and shipping of 15% of `amount`, at most \$10.00. `spendLimit` is also the amount checked against the agent's policy. Each platform sets the largest `amount` an agent may ask for. The outcome depends on the policy: * **Issued at once (`201`):** the limit is under the policy's approval threshold and the pool's, and `defaultExecutionMode` is `AUTO`. The response is the `AgentCard` with `purchase` set. * **Waits for approval (`202`):** otherwise. The response is an `ISSUE_PURCHASE_CARD` `AgentAction` in `PENDING_APPROVAL`, and your platform receives `AGENT_ACTION.PENDING_APPROVAL`. Approval issues the card and sets the action's `cardId`. The agent polls `GET /agents/me/actions/{actionId}`. * **Refused (`403`):** the policy denies it outright, for example over a spending limit. The error's reason starts with `Agent policy denied the purchase card:` followed by the denial. The card closes after its first approved authorization, and Grid closes it at `purchase.expiresAt` if it is still unused. An agent may hold only a limited number of open purchase cards, counting those awaiting approval; beyond that the request is refused with `409 PURCHASE_CARD_LIMIT_REACHED`. `Idempotency-Key` is required, because issuance cannot be reversed. Keys are scoped to the agent: a retry with the same key returns what the first request created (the card with `201`, or the action with `202` while it is pending, rejected or failed), and reusing a key for a different request is refused with `409`. ### Request a recurring card For a subscription, a card saved with a merchant, a hotel or car rental, or an order that ships in parts, add `"recurring": true`: ```json theme={null} { "kind": "PURCHASE", "recurring": true, "fundingSource": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "amount": 1599, "description": "Monthly music subscription at example-music.com" } ``` * It always becomes an `ISSUE_PURCHASE_CARD` action awaiting the customer's approval (`202`), whatever the policy's threshold or mode. A request the policy denies is still refused with `403`. * It locks to the first merchant that charges it and declines every other merchant. That merchant may charge it again, up to `amount` in any trailing month, with no tax or shipping allowance added, so include those in `amount`. * It has no expiry and stays open until the customer or your platform closes it with `PATCH /cards/{id}`, sending `status: CLOSED` together with a `substatus` and a `reason`, which the endpoint requires whenever `status` is set. Pausing the agent declines its charges without closing it; deleting the agent freezes it. * Recurring cards have their own, smaller limit on open cards, separate from single-use cards. ### Read the card details At checkout, the agent calls `POST /agents/me/cards/{cardId}/reveal` on an open purchase card. Grid returns a short-lived signed URL of the card processor's page that shows the full number, expiry and CVV: ```json theme={null} { "panEmbedUrl": "https://embed.lithic.com/iframe/...?t=...", "expiresAt": "2026-05-08T14:16:00Z" } ``` The card details never pass through Grid. Open the URL right away where the purchase is being made, and never store or log it. Every reveal is audit-logged with the agent as the actor, and reveals are rate limited per agent and per card (`429 RATE_LIMITED`). Only `PURCHASE` cards can be revealed by the agent; revealing a closed or expired one answers `409 CARD_NOT_MUTABLE`. ### Raise the amount before checkout If the total grows at checkout, for example when shipping is added, the agent can raise an open, unused single-use card with `POST /agents/me/cards/{cardId}/increase`, an `Idempotency-Key`, the new `amount` (strictly higher, in USD cents) and a `reason` the customer sees. It returns an `ISSUE_PURCHASE_CARD` action with `purchaseCard.increaseCardId` set, applied at once or waiting for approval as the current policy requires, and always waiting if the original card required approval. Recurring cards cannot be increased. This needs `ISSUE_CARDS` and `MANAGE_CARDS`. ## Standard agent cards A standard card is a virtual card issued for the agent, whose spend comes from the customer's account. Issue it from your backend with `POST /cards` and `agentId`, or let the agent issue its own with `POST /agents/me/cards` and no `kind`: ```json theme={null} { "fundingSource": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" } ``` * An agent has at most one standard card that is not `CLOSED`; another request is refused with `409 AGENT_CARD_EXISTS`. * New cards start `PROCESSING` and send the same card status webhooks as cards issued with `POST /cards`. Charges are declined while the agent is paused. * The agent can read the card (`GET /agents/me/cards`, `GET /agents/me/cards/{cardId}`), and with `PATCH /agents/me/cards/{cardId}` freeze it or tighten its merchant categories. It can never loosen or unfreeze a card; your platform does that with `PATCH /cards/{id}`. * `effectiveBlockedMccs` lists every merchant category code the card declines, Grid's agent defaults included. * The card details are revealed to the customer, by your platform, with `POST /cards/{id}/reveal`. An agent cannot reveal a standard card. * With `MANAGE_CARDS`, the agent can add its standard card to Apple Pay, Google Pay or Samsung Pay on the cardholder's phone with `POST /agents/me/cards/{cardId}/tokenize`. Confirm with the cardholder first: a wallet token spends like the card. ### Business customers Where your platform enables agents for business customers, an agent of a `BUSINESS` customer may be issued standard and purchase cards once the business's KYB status is `APPROVED`. Every request must name the person the card is for in `cardholder` (`firstName`, `lastName`, `email`, `phoneNumber` in E.164, and a complete US `address`). The card is issued on the business's card account with that person as its authorized user, and carries the agent's `actingFor`. ### Hand a card to a new agent Deleting an agent freezes the standard card issued for it, and a frozen card still counts against the customer's card limit. When the customer reconnects the same agent software as a new agent, call `POST /agents/{agentId}/card-handover` on the new agent to move the card to it and unfreeze it. Pass `previousAgentId` to pick the deleted agent's card, or `kind=PURCHASE` to move recurring purchase cards one at a time (call again until `204`). For `kind=PURCHASE`, the new agent must first hold an `ACTIVE` delegated key on the Embedded Wallet that funds the card; a card on a wallet where it holds none is skipped, and the call can answer `204` even though that card was not moved. Otherwise `204` means there is nothing to hand over. Single-use purchase cards are never handed over. ## Stored cards A stored card is a card the customer already owns, such as their personal Visa, that Grid stores, encrypted, so the agents they choose can use it, one approved purchase at a time. Stored cards are available for individual customers. > [!NOTE] > > **How the card number stays protected.** Your app collects the card with Evervault's Card UI component, which encrypts the number and security code in the customer's browser before they reach your servers. Grid only ever holds the encrypted values. When an approved use or the owner needs the number, Grid issues a single-use link that goes through Evervault's Relay, which decrypts the number on its way to whoever opens the link. No Grid system stores, logs or returns the number itself, and Grid never stores the security code. Your servers handle only ciphertext, which keeps the raw card number out of your environment. ### Store a card ```bash theme={null} curl -X POST "$GRID_BASE_URL/customers/Customer:019542f5-b3e7-1d02-0000-000000000010/stored-cards" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "number": "ev:Tk9D:number:...$", "cvc": "ev:Tk9D:cvc:...$", "lastFour": "0005", "expiryMonth": 8, "expiryYear": 2029, "holderName": "Jane Doe", "billingAddress": { "line1": "123 Main Street", "city": "San Francisco", "state": "CA", "postalCode": "94105", "country": "US" }, "nickname": "Personal Visa" }' ``` * `number` and `cvc` must be Evervault ciphertext (starting `ev:`). A plaintext value is refused with `400 INVALID_INPUT` and never stored or logged. * Grid verifies the card with its issuer (security code, billing address and name) before storing it. A card the issuer does not confirm is refused with `400 STORED_CARD_VERIFICATION_FAILED`. The security code is then discarded. * The response is the `StoredCard`: display fields (`lastFour`, `brand`, `funding`, `issuerCountry`, expiry, `nickname`), `status: ACTIVE`, and `agentIds: []`. A new card is shared with no agent. List the customer's active cards with `GET /customers/{customerId}/stored-cards`, and read one (including a removed one) with `GET /customers/{customerId}/stored-cards/{storedCardId}`. ### Share it with an agent ```bash theme={null} curl -X PUT "$GRID_BASE_URL/customers/Customer:019542f5-b3e7-1d02-0000-000000000010/stored-cards/StoredCard:019542f5-b3e7-1d02-0000-000000000077/agents/Agent:019542f5-b3e7-1d02-0000-000000000042" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` The agent must belong to the customer and also needs the `USE_STORED_CARDS` permission, which is never granted by default. Granting access hands the agent spending power on the customer's own card: confirm it with the customer (for example with a passkey or an emailed code) before calling. Stop an agent using the card with `DELETE` on the same path, which also clears its auto-approved merchants, stops its unopened links, and fails its uses awaiting approval. ### How the agent uses it 1. The agent lists the cards it may use with `GET /agents/me/stored-cards` (display fields only). 2. It asks to use one with `POST /agents/me/stored-cards/{storedCardId}/reveal`, naming the `merchant`, the `amount` it expects to be charged, the `currency`, the `purpose`, and `needsCvc` when the checkout asks for the security code. 3. Unless the customer chose to auto-approve this agent at this merchant up to this amount, Grid answers `202` with a `USE_STORED_CARD` action in `PENDING_APPROVAL`. A use with `needsCvc` always waits, because the customer enters the security code when approving. 4. Your platform shows the request and approves it. For a `needsCvc` use, collect the security code with Evervault's Card component and send it as `storedCardCvc`. Send `autoApproveMerchant: true` if the customer wants to skip asking next time at this merchant, up to this amount. 5. Once the action is `APPROVED`, the agent calls the same endpoint with only `actionId` and receives `200` with a single-use `detailsUrl`, valid for five minutes, plus the holder name and billing address for the checkout form. 6. The agent opens `detailsUrl` once, right away, where the purchase is being made. It answers the number, expiry, holder name and, when `cvcIncluded` is `true`, the security code. Each approved use issues one link; asking again with the same `actionId` is refused with `409`. Uses are rate limited per card, per agent and per customer (`429 RATE_LIMITED`), and every use is audit-logged with the agent as the actor. An auto-approved use is created `APPROVED` and sent to your platform as an `AGENT_ACTION.APPROVED` webhook, so you can tell the customer about every use. > [!WARNING] > > Grid cannot enforce the amount on a stored card: the merchant charges the customer's own card, and the merchant and amount are the agent's word. Show them as such, and let the customer remove access at any time. ### Show the card to its owner To show the customer their own stored card number, call `POST /customers/{customerId}/stored-cards/{storedCardId}/reveal` only after verifying the customer is present, for example with a fresh step-up. Grid returns a single-use `detailsUrl`, valid for two minutes, that answers the number, expiry and holder name, never the security code. Hand it only to that customer's browser, for Evervault's Reveal component to open: whoever opens it receives the number in plaintext. Only an `ACTIVE` card can be revealed, reveals are rate limited (`429` with `Retry-After`), and each issue and opening is recorded in the card's audit log. ### Remove a card `DELETE /customers/{customerId}/stored-cards/{storedCardId}` erases the encrypted number, holder name and billing address at once, so the card cannot be recovered. Every agent loses access, unopened links stop working, and uses awaiting approval fail. The card stays readable with its display fields and `status: REMOVED`. ## Next steps * [Approvals & audit](/agent-accounts/approvals-and-audit) * [Sandbox testing](/agent-accounts/platform-tools/sandbox-testing): simulate card authorizations # Creating agents Source: https://agents-docs.lightspark.com/agent-accounts/creating-agents Create an agent for a customer, connect the agent software with a one-time device code, and delegate signing on the customer's Global Account. > [!NOTE] > > The Agents API must be enabled for your platform; until it is, agent endpoints answer `403`. To enable it, [book a demo](https://www.lightspark.com/contact) or reach out to your Lightspark contact. An agent goes from nothing to spending in three steps: your backend creates it, the agent software redeems a device code for its own token, and the customer authorizes Grid to sign the agent's sends from their Global Account. ## Create an agent Call `POST /agents` from your backend with platform credentials. `name`, `customerId` and `policy` are required. See [Policies & permissions](/agent-accounts/policies-and-permissions) for every policy field. ```bash theme={null} curl -X POST "$GRID_BASE_URL/agents" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \ -d '{ "name": "Shopping assistant", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "policy": { "permissions": ["VIEW_TRANSACTIONS", "CREATE_QUOTES", "EXECUTE_QUOTES"], "defaultExecutionMode": "APPROVAL_REQUIRED", "spendingLimits": { "currency": "USD", "perTransactionLimit": 50000, "dailyLimit": 200000, "dailyTransactionLimit": 5 } } }' ``` **Response:** `201 Created` ```json theme={null} { "agent": { "id": "Agent:019542f5-b3e7-1d02-0000-000000000001", "name": "Shopping assistant", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "isPaused": false, "isConnected": false, "policy": { "permissions": ["VIEW_TRANSACTIONS", "CREATE_QUOTES", "EXECUTE_QUOTES"], "defaultExecutionMode": "APPROVAL_REQUIRED", "spendingLimits": { "currency": "USD", "perTransactionLimit": 50000, "dailyLimit": 200000, "dailyTransactionLimit": 5 } }, "usage": { "dailyTransactionCount": 0, "dailySpend": 0, "monthlySpend": 0 }, "createdAt": "2025-07-21T17:32:28Z", "updatedAt": "2025-07-21T17:32:28Z" }, "deviceCode": { "code": "jqzJR3AgI9OAwb_enypARw", "agentId": "Agent:019542f5-b3e7-1d02-0000-000000000001", "expiresAt": "2025-07-21T17:47:28Z", "redeemed": false } } ``` ### Optional fields | Field | Use | | - | - | | `spendingPoolId` | Put the agent in one of the customer's [spending pools](/agent-accounts/spending-limits), where your platform enables them. The agent's policy must be in the pool's currency. | | `actingFor` | Your own reference for the person the agent acts for, such as a member id within a business (1 to 255 characters). Grid stores it as given, never interprets it, and copies it onto every card the agent is issued. Required for a `BUSINESS` customer, optional for an `INDIVIDUAL`, and it cannot be changed later. | Agents of `BUSINESS` customers are available only where your platform enables them, and `actingFor` is required for them. Until the business's KYB status is `APPROVED`, its agents' sends and deposits are held to the same limits as an unverified individual's, counted across the whole business. ### Idempotent creation `Idempotency-Key` is optional on `POST /agents` but you should always send one. Without a key, Grid cannot recognize a retry, so each request creates its own agent. * A retry with a key already used returns the agent the first request created, with `200` instead of `201`, and a **new device code** that replaces any unredeemed one. * The retry is compared with the agent as it is now. Reusing a key with a different customer, name, policy, `actingFor` or `spendingPoolId`, after the agent's policy or pool was changed, for an agent that was deleted, or for one that is already connected, is rejected with `409`. Use a new key for a new agent. ## Connect the agent software The `deviceCode.code` is an opaque 22-character string the agent software exchanges for its own credential. Hand it to the customer to paste into the agent, or pass it to the agent through your own install flow. * Grid stores only a hash of the code, so it is returned only when it is issued. * It can be redeemed once, and only before `expiresAt`. Codes expire 15 minutes after they are issued by default; Grid can set your platform's lifetime between 1 and 60 minutes. * An agent has at most one unredeemed code. If it expires or is lost, issue a new one with `POST /agents/{agentId}/device-codes`, which invalidates the previous code. Regenerating is refused with `409` once the agent is connected. ### Redeem the code The agent software calls `POST /agents/device-codes/redeem`. It needs no platform credentials: the code itself is the proof. ```bash theme={null} curl -X POST "$GRID_BASE_URL/agents/device-codes/redeem" \ -H "Content-Type: application/json" \ -d '{ "code": "jqzJR3AgI9OAwb_enypARw" }' ``` ```json theme={null} { "agentId": "Agent:019542f5-b3e7-1d02-0000-000000000001", "agentName": "Shopping assistant", "accessToken": "gat_ed0ad25881e234cc28fb2dec0a4fe64e4172a3b9", "policy": { "permissions": ["VIEW_TRANSACTIONS", "CREATE_QUOTES", "EXECUTE_QUOTES"], "defaultExecutionMode": "APPROVAL_REQUIRED", "spendingLimits": { "currency": "USD", "perTransactionLimit": 50000, "dailyLimit": 200000, "dailyTransactionLimit": 5 } } } ``` > [!WARNING] > > `accessToken` is returned only once and cannot be retrieved again. The agent software must store it securely and send it as `Authorization: Bearer ` on every `/agents/me/...` call. Grid cannot return it again, and a connected agent cannot be issued a new device code. Redemption is rate limited. A caller over the limit gets `429 RATE_LIMITED` with a `Retry-After` header and should wait that long before retrying. ### Watch for the connection From your backend, poll `POST /agents/device-codes/status` with the code. `status` is one of: | Status | Meaning | | - | - | | `PENDING` | Active and not yet redeemed | | `REDEEMED` | Redeemed; the agent is connected | | `EXPIRED` | Expired before it was redeemed | | `INVALIDATED` | Superseded by a newer code, or otherwise invalidated | Once redeemed, the agent's `isConnected` is `true`. You can also list connected agents with `GET /agents?isConnected=true`. ### What the agent can call With its token, the agent reads its own profile, policy and current usage from `GET /agents/me`, and calls the operations its permissions allow: quotes and sends, deposits, external accounts, cards, and identity. Every `/agents/me/...` request is bound to the agent's customer; the agent cannot name another customer, and a resource of another customer answers `404`. ## Delegate signing on the Global Account Sends from a Global Account must be signed for the wallet. For an agent, the customer authorizes Grid to sign on the agent's behalf by creating a delegated key for that agent and wallet with `POST /auth/delegated-keys`: ```json theme={null} { "agentId": "Agent:019542f5-b3e7-1d02-0000-000000000001", "internalAccountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "nickname": "Shopping assistant key" } ``` Creation is a three-leg signed-retry flow, signed with the session key of a verified credential on the wallet: 1. Call `POST /auth/delegated-keys` with no signature headers. Grid responds `202` with a `payloadToSign` and `requestId`. 2. Stamp `payloadToSign` with the customer's session key and retry with `Grid-Wallet-Signature` and `Request-Id`. Grid responds with a second `202` and a new `payloadToSign`. 3. Stamp the new payload and retry once more. Grid responds `201` with the `DelegatedKey` in `ACTIVE` status. Send the same body on all three legs. See [Client keys & signing](/global-accounts/client-keys) for building the stamp. Once the key is `ACTIVE`, Grid uses it to sign the agent's sends that its policy allows or the customer approves; your platform never handles the key material. Each agent and wallet pair may have at most one unrevoked (`PENDING` or `ACTIVE`) key. A flow abandoned after the second leg leaves a `PENDING` key that blocks a new one until you revoke it. > [!WARNING] > > A delegated key authorizes signing for the wallet and cannot itself be scoped to amounts or recipients. The agent's policy, spending pool and the customer's approvals are what bound it. Revoke the key with `DELETE /auth/delegated-keys/{id}` when the customer disconnects the agent. ## Manage the agent | Task | Call | | - | - | | Rename, pause or resume, or move between spending pools | `PATCH /agents/{agentId}` with `name`, `isPaused` or `spendingPoolId` (`null` takes it out of its pool) | | Change the policy | `PATCH /agents/{agentId}/policy` (see [Policies & permissions](/agent-accounts/policies-and-permissions)) | | Read one agent | `GET /agents/{agentId}` | | List agents | `GET /agents`, filtered by `customerId`, `actingFor`, `isPaused`, `isConnected`, or created and updated time; paginated with `limit` (default 20, max 100) and `cursor` | | Delete permanently | `DELETE /agents/{agentId}`; connected agent software loses access immediately | A paused agent cannot initiate any action. Deleting an agent freezes the standard card issued for it; if the customer reconnects the same agent software as a new agent, `POST /agents/{agentId}/card-handover` moves that card to the new agent and unfreezes it. See [Cards](/agent-accounts/cards#hand-a-card-to-a-new-agent). ## Next steps * [Policies & permissions](/agent-accounts/policies-and-permissions) * [Funding](/agent-accounts/funding) * [Payments](/agent-accounts/payments) # Funding Source: https://agents-docs.lightspark.com/agent-accounts/funding Get money into the Global Account an agent spends from: platform deposits and quotes, bank transfers, pulls from the customer's own bank account or debit card, and agent-created Lightning invoices and stablecoin addresses. > [!NOTE] > > The Agents API must be enabled for your platform; until it is, agent endpoints answer `403`. To enable it, [book a demo](https://www.lightspark.com/contact) or reach out to your Lightspark contact. An agent spends from the customer's Global Account, so funding an agent account means funding that Embedded Wallet. Incoming funds never need the customer's signature or approval. There are two ways in: your platform funds the account as it funds any internal account, or the agent creates a deposit target the customer (or anyone) pays. ## Funded by your platform A Global Account is an internal account, so every way you already fund internal accounts works. Create a quote with the Global Account's `InternalAccount` id as the `destination`, from any source currency your platform supports, and Grid converts into the wallet's currency on execute. A real-time funding source returns `paymentInstructions` the customer follows to pay in. See [Fund the account](/global-accounts/implementation-overview#4-fund-the-account) in the Global Accounts walkthrough for the request. ### Bank transfers to the customer's account details Once a customer is verified, their USD internal account carries a US account and routing number: the `USD_ACCOUNT` entry in its `fundingPaymentInstructions`, which lists the payment rails it accepts (for example `ACH` and `WIRE`). Anyone can pay it, not only the customer: clients, employers and other people send an ordinary bank transfer on one of those rails, and the customer or your platform can share the details with any payer. The account number is unique to that account, so payers need no reference. ## Deposits the agent creates With the `RECEIVE_FUNDS` permission, the agent can create a deposit target for the customer's account with `POST /agents/me/deposits`. `Idempotency-Key` is required: a retry with the same key returns the same deposit. | `sourceNetwork` | `sourceCurrency` | What the agent gets | | - | - | - | | `LIGHTNING` | `USD` | A BOLT11 invoice for an exact USD `amount` (in cents, required), with an expiry | | `BASE`, `ETHEREUM`, `SOLANA`, `POLYGON`, `ARBITRUM` | The asset sent on that chain | A deposit address on that chain. No amount is needed | | `TRON` | `USDT` only | A deposit address on Tron | `destinationInternalAccountId` must belong to the agent's customer and be allowed by the agent's policy. To pull from the customer's own bank account or debit card instead, see [Pull from the customer's bank account](#pull-from-the-customers-bank-account) and [Pull from the customer's debit card](#pull-from-the-customers-debit-card). ```bash Lightning theme={null} curl -X POST "$GRID_BASE_URL/agents/me/deposits" \ -H "Authorization: Bearer $AGENT_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 2c7e9a1f-5b3d-4e8a-9c0f-6d1b4e8a2c75" \ -d '{ "sourceNetwork": "LIGHTNING", "sourceCurrency": "USD", "destinationInternalAccountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "amount": 2000 }' ``` ```bash Stablecoin address theme={null} curl -X POST "$GRID_BASE_URL/agents/me/deposits" \ -H "Authorization: Bearer $AGENT_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 9f1a3c5e-7b2d-4a6f-8e0c-3d5b7a9e1c42" \ -d '{ "sourceNetwork": "BASE", "sourceCurrency": "USDC", "destinationInternalAccountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" }' ``` **Response:** `201 Created` ```json theme={null} { "id": "AgentDeposit:019542f5-b3e7-1d02-0000-000000000001", "status": "PENDING", "sourceNetwork": "LIGHTNING", "sourceCurrency": "USD", "destinationInternalAccountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "destinationCurrency": "USDB", "targetType": "LIGHTNING_INVOICE", "target": "lnbc1pndepositpp5...", "paymentLinks": { "cashApp": "https://cash.app/launch/lightning/lnbc1pndepositpp5..." }, "expiresAt": "2026-10-09T18:05:00Z", "createdAt": "2026-10-09T18:00:00Z", "updatedAt": "2026-10-09T18:00:00Z" } ``` ### Lightning deposits The customer pays exactly `amount` in USD; deposit fees are deducted before the destination is credited, and `estimatedReceiveAmount` shows the estimate. The invoice must be paid by `expiresAt`. `paymentLinks.cashApp` opens the invoice in Cash App, and `paymentLinks.shortUrl` is a shortened link when one is available. ### Chain deposits A chain request returns a standing address that does not expire. Grid counts the first payment the address receives from that network in that currency within 24 hours of the request. While that window is open with nothing paid, a second request for the same account, network and currency is refused with `409`; the error gives the time the window closes. Where Grid funds a chain deposit through a quote instead, the address is the quote's, the deposit carries the quote's `expiresAt`, and `quoteId` names the quote. A payment that arrives after it expired is not converted: the deposit stays `EXPIRED`, `paidAfterExpiry` becomes `true`, and Lightspark recovers the funds by hand. Do not send it again. ### Tracking a deposit Read a deposit with `GET /agents/me/deposits/{depositId}`. `status` is `PENDING`, `PROCESSING`, `COMPLETED`, `EXPIRED`, `FAILED` or `REFUNDED`. The transaction that pays a deposit carries the deposit's `quoteId`, so you can match them, and your platform receives the usual `INCOMING_PAYMENT` webhook. Except for bank pulls, a deposit reported `FAILED` or `EXPIRED` can still become `COMPLETED` when funds arrive late; you then receive `INCOMING_PAYMENT` for its transaction again, now `COMPLETED`. A `503` means the deposit provider is unavailable or holding the order for review. Retry with the same `Idempotency-Key`. ### Pull from the customer's bank account Where your platform enables external-account pulls, the agent can ask to pull USD from one of the customer's own US bank accounts into their USDB Global Account with `sourceNetwork: ACH_PULL`. Until then, the request is refused with `403` (`ACH_PULL_NOT_ENABLED`). 1. Your backend validates the customer's bank account through Plaid and records it with `POST /agents/{agentId}/external-accounts/{externalAccountId}/ach-validations`. A pull needs an `ACTIVE` USD external account of the agent's customer with a recorded validation. 2. The agent creates the deposit with `POST /agents/me/deposits`, giving `sourceCurrency: USD`, `amount` in cents, `sourceExternalAccountId`, and the customer's USDB `EMBEDDED_WALLET` as `destinationInternalAccountId`. This moves no money. The deposit is `PENDING` and carries `achPull.authorization.text`, the debit authorization the customer must accept, and `achPull.limits`, how much more the customer can pull. 3. Your product shows that text verbatim to the signed-in customer (for a business, an admin). When they accept, your backend calls `POST /agents/{agentId}/deposits/{depositId}/authorize-debit` with platform credentials, sending the text's `textVersion` and `textSha256`. On success the debit is submitted, the deposit becomes `PROCESSING`, and the amount is credited to the Global Account at once. It becomes `COMPLETED` when the debit settles, or `FAILED` if Grid refuses it or the bank returns it (`achPull.returnCode`). A deposit that is not authorized within 30 minutes becomes `EXPIRED`. The agent can never authorize a debit itself. ### Pull from the customer's debit card Where your platform enables card pulls, the agent can ask to pull USD from one of the customer's own debit cards into their USDB Global Account with `sourceNetwork: DEBIT_CARD_PULL`. It works like the bank pull above, with a [stored card](/agent-accounts/cards#stored-cards) in place of a bank account. Grid stores the card encrypted, so your platform never handles the card number. Until card pulls are enabled, the request is refused with `403` (`DEBIT_CARD_PULL_NOT_ENABLED`). 1. The customer's debit card is stored with `POST /customers/{customerId}/stored-cards`, as in [Cards](/agent-accounts/cards#store-a-card). A pull needs an `ACTIVE` stored card that is a debit card, belongs to the agent's customer, and is shared with the agent. 2. The agent creates the deposit with `POST /agents/me/deposits`, giving `sourceCurrency: USD`, `amount` in cents, `sourceStoredCardId`, and the customer's USDB `EMBEDDED_WALLET` as `destinationInternalAccountId`. This moves no money. The deposit is `PENDING` and carries `debitCardPull.authorization.text`, the debit authorization the customer must accept, and `debitCardPull.limits`, how much more the customer can pull. 3. Your product shows that text verbatim to the signed-in customer and confirms their decision with a fresh step-up, such as a passkey or an emailed code. When they accept, your backend calls `POST /agents/{agentId}/deposits/{depositId}/authorize-debit` with platform credentials, sending the text's `textVersion` and `textSha256`. On success the card is charged, the deposit becomes `PROCESSING`, and the amount is credited to the Global Account at once. It becomes `COMPLETED` when the charge settles, or `FAILED` if Grid refuses it or the card issuer declines or reverses it (`debitCardPull.returnCode`). A deposit that is not authorized within 30 minutes becomes `EXPIRED`. The agent can never authorize a debit itself. Track the deposit and its `INCOMING_PAYMENT` webhooks as in [Tracking a deposit](#tracking-a-deposit). ## Limits before verification Until a business customer's KYB status is `APPROVED`, its agents' sends and deposits are held to the same limits as an unverified individual's, counted across the whole business. With `MANAGE_IDENTITY`, an agent can help an individual customer finish verification: read and update identity details (`GET`/`PATCH /agents/me/customer`), submit for KYC (`POST /agents/me/verifications`), or generate a single-use hosted KYC link (`POST /agents/me/kyc-link`). ## Funding in sandbox In sandbox, credit any internal account instantly with `POST /sandbox/internal-accounts/{accountId}/fund`. See [Sandbox testing](/agent-accounts/platform-tools/sandbox-testing). ## Next steps * [Payments](/agent-accounts/payments) * [Webhooks](/agent-accounts/platform-tools/webhooks) # Agents API integration Source: https://agents-docs.lightspark.com/agent-accounts/implementation-overview For Grid platforms: create agents for your customers, connect their agent software, delegate signing, fund the account, and handle approvals from your backend. > [!NOTE] > > The Agents API must be enabled for your platform; until it is, agent endpoints answer `403`. To enable it, [book a demo](https://www.lightspark.com/contact) or reach out to your Lightspark contact. This guide is for Grid platforms adding agents for their own customers. If you build an AI app and want your users to get a Lightspark account inside it, see [AI app connector](/agent-accounts/ai-app-connector) instead. This page walks the happy path from a verified customer to an agent that pays on its own under a limit and asks the customer above it. Each step links to the page that covers it in depth. ## Prerequisites * The Agents API is enabled for your platform. Every agent endpoint answers `403` until it is. * The customer exists and has completed KYC (or KYB for a business). In sandbox, customers are KYC-approved on creation. * The customer has a [Global Account](/global-accounts) (an `EMBEDDED_WALLET` internal account), with a verified authentication credential you can use to sign. See [Implementation overview](/global-accounts/implementation-overview) for Global Accounts. ## Two kinds of credentials | Caller | Authenticates with | Calls | | - | - | - | | **Your backend** | Your platform API token, HTTP Basic auth | `POST /agents`, `PATCH /agents/{agentId}/policy`, `POST /agents/{agentId}/actions/{actionId}/approve`, and every other platform endpoint | | **The agent software** | The agent's access token, `Authorization: Bearer ` | Endpoints under `/agents/me/...`, always scoped to the agent's customer and policy | The agent never sees your platform credentials, and your backend never needs the agent's token. ## Sequence ```mermaid theme={null} sequenceDiagram participant C as Customer participant P as Your backend participant G as Grid participant A as Agent software P->>G: POST /agents (customer, policy) G-->>P: Agent + one-time device code P-->>C: Show device code C->>A: Enter device code A->>G: POST /agents/device-codes/redeem G-->>A: Access token + policy C->>P: Authorize agent on the Global Account P->>G: POST /auth/delegated-keys (agentId, signed by customer) A->>G: POST /agents/me/quotes, then /execute alt Within policy G-->>A: AgentAction APPROVED + transaction else Needs approval G-->>A: AgentAction PENDING_APPROVAL G->>P: Webhook AGENT_ACTION.PENDING_APPROVAL P-->>C: Ask in your app C->>P: Approve P->>G: POST /agents/{agentId}/actions/{actionId}/approve G->>P: Webhook AGENT_ACTION.APPROVED end G->>P: OUTGOING_PAYMENT webhooks ``` ## Walkthrough **1. Create the agent** Your backend creates the agent for a customer with a policy. Send an `Idempotency-Key` so a retry returns the same agent instead of creating a second one. ```bash theme={null} curl -X POST "$GRID_BASE_URL/agents" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \ -d '{ "name": "Shopping assistant", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "policy": { "permissions": ["VIEW_TRANSACTIONS", "CREATE_QUOTES", "EXECUTE_QUOTES", "RECEIVE_FUNDS"], "defaultExecutionMode": "AUTO", "spendingLimits": { "currency": "USD", "perTransactionLimit": 50000, "dailyLimit": 100000, "dailyTransactionLimit": 10 }, "approvalThresholds": { "currency": "USD", "amount": 2500 } } }' ``` The `201` response carries the `agent` and a `deviceCode`. See [Creating agents](/agent-accounts/creating-agents). **2. Connect the agent software** Show the customer the `deviceCode.code`. The agent software redeems it, without platform credentials, and receives its access token once: ```bash theme={null} curl -X POST "$GRID_BASE_URL/agents/device-codes/redeem" \ -H "Content-Type: application/json" \ -d '{ "code": "jqzJR3AgI9OAwb_enypARw" }' ``` Poll `POST /agents/device-codes/status` from your backend to see when it is `REDEEMED`. The agent's `isConnected` becomes `true`. **3. Let the agent sign from the Global Account** The customer authorizes a delegated key for this agent and their Embedded Wallet with `POST /auth/delegated-keys` and `agentId`. Creation is a three-leg signed-retry flow signed with the customer's session key, so it always involves the customer. Without an `ACTIVE` key, the agent's quote executions from the wallet are refused. See [Creating agents](/agent-accounts/creating-agents#delegate-signing-on-the-global-account). **4. Fund the account** Fund the Global Account the way you fund any internal account, or let the agent create a deposit target with `POST /agents/me/deposits` (Lightning invoice or stablecoin address). In sandbox, use `POST /sandbox/internal-accounts/{accountId}/fund`. See [Funding](/agent-accounts/funding). **5. The agent pays** The agent quotes with `POST /agents/me/quotes` and executes with `POST /agents/me/quotes/{quoteId}/execute`. Execution returns an `AgentAction`: `APPROVED` with a `transaction` when the policy allows it, or `PENDING_APPROVAL` when the customer must decide. See [Payments](/agent-accounts/payments). **6. Handle approvals** Subscribe to `AGENT_ACTION.PENDING_APPROVAL`, show the customer the request, and call `POST /agents/{agentId}/actions/{actionId}/approve` or `.../reject` with their decision. The agent learns the outcome by polling `GET /agents/me/actions/{actionId}`. See [Approvals & audit](/agent-accounts/approvals-and-audit). **7. Give the customer control** Let the customer change the policy (`PATCH /agents/{agentId}/policy`), pause the agent (`PATCH /agents/{agentId}` with `isPaused: true`), or delete it (`DELETE /agents/{agentId}`). Paused agents cannot initiate any action; deleted agents lose access immediately. ## Where to next > [card] [Policies & permissions](/agent-accounts/policies-and-permissions) > > Every policy field, and how Grid decides between executing, asking and refusing. > [card] [Cards](/agent-accounts/cards) > > Purchase cards, recurring cards, and the customer's stored cards. > [card] [Spending limits](/agent-accounts/spending-limits) > > Share one budget across a customer's agents with spending pools. > [card] [Sandbox testing](/agent-accounts/platform-tools/sandbox-testing) > > Run the whole flow without moving real money. # Agent Accounts Source: https://agents-docs.lightspark.com/agent-accounts/index Let your customers' AI agents get paid, pay people and spend, from an account the customer owns, under limits and approvals Grid enforces. > [!NOTE] > > The Agents API must be enabled for your platform; until it is, agent endpoints answer `403`. To enable it, [book a demo](https://www.lightspark.com/contact) or reach out to your Lightspark contact. Let your customers connect an AI agent to their account and hand it real financial work: getting paid, paying people and suppliers, buying online, and splitting revenue. This works for individuals and for businesses. A business can give each team member's agent its own budget on one shared balance, pay contractors who have no account, and pay a whole list of people in one approved run. The customer keeps ownership of the account. Grid checks every agent action against the policy the customer set, and either runs it, refuses it, or holds it for the customer to approve. The agent can run in an AI app, on the customer's device, or in your own product. Grid does not run the agent - it provides the agent's credentials, policy checks, approvals and the audit trail. ## Two ways to offer agent accounts > [card] [Add Lightspark to your AI app](/agent-accounts/ai-app-connector) > > For AI companies. List Lightspark as a connector in your app. Your users sign up, verify and approve payments with Lightspark, and your assistant calls Lightspark over MCP. You run no payments backend. > [card] [Add agents to your Grid platform](/agent-accounts/implementation-overview) > > For Grid platforms. Your backend creates an agent for each of your customers, sets its policy, and shows approvals in your own app. Your customers keep their accounts with you. Both paths give the agent the same capabilities. The rest of this tab documents the Agents API, the path for Grid platforms. ## What an agent can do ![](/images/icons/arrow-inbox.svg) Add money and get paid Receive stablecoins, Lightning payments and bank transfers from anyone into the customer's account details, and pull from the customer's own bank account with a recorded debit authorization or from their debit card. ![](/images/icons/paper-plane-top-right.svg) Pay people Pay someone by phone number or email even if they have no account, pay up to 50 people in one approved run, or send stablecoins, Lightning payments and payments directly to a bank account. ![](/images/icons/credit-card1.svg) Buy online Use its own virtual card, a single-use or recurring card for one merchant, or, with the customer's permission, one of the customer's saved cards. ![](/images/icons/coins.svg) Split revenue automatically Propose a standing rule that shares incoming payments with partners, collaborators or the customer's other accounts, active once the customer approves it. ## For businesses A business customer can run its money through agents the same way an individual can, with controls built for a team: * **Bank details to get paid.** Once verified, a business gets a US bank account and routing number: the `USD_ACCOUNT` entry in the `fundingPaymentInstructions` of its USD internal account. Anyone can pay it by ordinary bank transfer, such as a client or an employer. * **Invoicing.** The [Lightspark connector](/agent-accounts/ai-app-connector) lets an agent send a client an invoice with its own payment page and matches the payment to the invoice. The Agents API has no invoice object; if you invoice from your own product, point payers at the bank details above. * **An agent per person.** Each agent records which team member it acts for (`actingFor`), and that reference is copied onto every card it is issued. * **Team budgets on one balance.** Put agents in a [spending pool](/agent-accounts/spending-limits) to cap what they spend together each week, each month or in total, and which kinds of payment they may make. Pools hold no money, so there is nothing to move between them. * **Pay contractors and suppliers.** Pay someone by phone number or email with a [claimable payout](/agent-accounts/payments#pay-a-person-by-phone-or-email), one at a time or in a [payout run](/agent-accounts/payments#pay-many-people-at-once). * **Share revenue.** [Settlement rules](/agent-accounts/payments#split-incoming-payments) split incoming payments by the shares the business approves. ## Key concepts | Concept | What it is | | - | - | | **Customer** | The person or business whose money the agent spends. An agent belongs to exactly one customer and can only see and act on that customer's accounts. | | **Agent** | A delegated actor with a name, a policy, and its own credential (`Agent:...`). Your backend creates it; the agent software connects by redeeming a one-time device code. | | **Policy** | What the agent may do (permissions), how it executes (automatically or only with approval), how much it may spend (spending limits), which accounts it may use, and above what amount it must ask. | | **Agent action** | Every money movement an agent asks for becomes an `AgentAction` (`AgentAction:...`). Grid executes it at once or holds it in `PENDING_APPROVAL` until your platform approves or rejects it. | | **Spending pool** | A limit several of a customer's agents share, on top of each agent's own policy. A pool holds no money. | | **Delegated key** | A Grid-custodied signing key the customer authorizes once, so Grid can sign the agent's approved sends from their Global Account without the customer signing each one. | | **Cards** | Agents can be issued their own virtual cards, request single-use or recurring purchase cards, or use a card the customer already owns, which Grid stores encrypted so your platform never handles the card number. | ## Core capabilities ![](/images/icons/shield.svg) Grid-enforced policy Grid checks permissions, per-agent limits, shared team budgets and approval thresholds before any agent request runs, including card purchases. ![](/images/icons/receipt-check.svg) Customer approvals Requests over the threshold wait for the customer. Grid sends you the full request in a webhook so you can ask them in your own UI. ![](/images/icons/coins.svg) Shared spending pools Hold a customer's agents to one budget together, with its own payment types and approval threshold. ![](/images/icons/bell.svg) Webhooks and audit Every request and decision is an `AgentAction` you can list, and Grid sends a webhook at each step. ## How it fits with Global Accounts An agent's sends and purchase cards are paid from the customer's [Global Account](/global-accounts), the self-custodial Embedded Wallet (`type: EMBEDDED_WALLET`) Grid provisions for them. Outbound payments from a Global Account normally need the customer's signature on every send. For an agent, the customer instead authorizes a delegated key for that agent and wallet once, and Grid signs only the sends its policy and the customer's approvals allow. The customer can pause the agent, revoke the key, or delete the agent at any time. ## Where to next > [card] [Playground](/demo) > > Give an agent an account and watch the API calls behind each step. > [card] [AI app connector](/agent-accounts/ai-app-connector) > > List Lightspark in your AI app so your users get an account inside your assistant. > [card] [Agents API integration](/agent-accounts/implementation-overview) > > For Grid platforms: create an agent, connect it, fund the account, and handle approvals. > [card] [Policies & permissions](/agent-accounts/policies-and-permissions) > > Decide what an agent may do, how much it may spend, and when it must ask. > [card] [Approvals & audit](/agent-accounts/approvals-and-audit) > > Show approval requests to the customer and send their decision to Grid. # Payments Source: https://agents-docs.lightspark.com/agent-accounts/payments How an agent quotes and sends money from the customer's Global Account, pays people by phone or email, and pays many people at once, within its policy. > [!NOTE] > > The Agents API must be enabled for your platform; until it is, agent endpoints answer `403`. To enable it, [book a demo](https://www.lightspark.com/contact) or reach out to your Lightspark contact. Agents send money with the same quote-then-execute pattern as the rest of Grid, through agent-scoped endpoints authenticated with the agent's token. Every send is checked against the agent's [policy](/agent-accounts/policies-and-permissions) and [spending pool](/agent-accounts/spending-limits), and either runs at once or waits for the customer's [approval](/agent-accounts/approvals-and-audit). ## Before the agent can send * The policy grants `CREATE_QUOTES` and `EXECUTE_QUOTES`. * The customer has an `ACTIVE` [delegated key](/agent-accounts/creating-agents#delegate-signing-on-the-global-account) for this agent on the Global Account the agent pays from. * The Global Account is funded. See [Funding](/agent-accounts/funding). The agent finds its source account with `GET /agents/me/internal-accounts?type=EMBEDDED_WALLET`. ## Quote a send `POST /agents/me/quotes` takes a `source`, a `destination`, `lockedCurrencySide` and `lockedCurrencyAmount`. The source is an `EMBEDDED_WALLET` internal account of the agent's customer. The destination is one of: | `destinationType` | Pays | Notes | | - | - | - | | `ACCOUNT` | Another Embedded Wallet on your platform, one of the customer's active `SPARK_WALLET` external accounts in the source's currency, or one of the customer's active bank accounts | Save a Spark address or bank account first with `POST /agents/me/external-accounts` | | `CHAIN_ADDRESS` | A `USDC` or `USDT` address on `BASE`, `ETHEREUM`, `SOLANA`, `POLYGON`, `ARBITRUM` or `TRON` | Sends `USDB` from the wallet. `TRON` supports `USDT` only and `BASE` `USDC` only. Grid records and screens the address before quoting | | `LIGHTNING_INVOICE` | A BOLT11 invoice | The invoice must name an amount in whole satoshis, be for your platform's Bitcoin network, and not expire within the next minute | ```bash Stablecoin address theme={null} curl -X POST "$GRID_BASE_URL/agents/me/quotes" \ -H "Authorization: Bearer $AGENT_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 3b2f9c1e-6d4a-4e8b-a0f2-7c9d1e5b3a64" \ -d '{ "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" }, "destination": { "destinationType": "CHAIN_ADDRESS", "network": "BASE", "currency": "USDC", "address": "0x1234567890abcdef1234567890abcdef12345678" }, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 2500 }' ``` ```bash Lightning invoice theme={null} curl -X POST "$GRID_BASE_URL/agents/me/quotes" \ -H "Authorization: Bearer $AGENT_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 8e4c2a7f-1b3d-4f9e-b6a0-5d2c8e7f1a93" \ -d '{ "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" }, "destination": { "destinationType": "LIGHTNING_INVOICE", "invoice": "lnbc15u1p3xnhl2pp5jptserfk3zk4qy42tlucycrfwxhydvlemu9pqr93tuzlv9cc7g3sdqsvfhkcap3xyhx7un8cqzpgxqzjcsp5f8c52y2stc300gl6s4xswtjpc37hrnnr3c9wvtgjfuvqmpm35evq9qyyssqy4lgd8tj637qcjp05rdpxxykjenthxftej7a2zzmwrmrl70fyj9hvj0rewhzj7jfyuwkwcg9g2jpwtk3wkjtwnkdks84hsnu8xps5vsq4gj5hs" }, "lockedCurrencySide": "RECEIVING", "lockedCurrencyAmount": 1500 }' ``` ```bash Saved Spark address theme={null} curl -X POST "$GRID_BASE_URL/agents/me/quotes" \ -H "Authorization: Bearer $AGENT_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: c5a1e9d3-2f7b-4c6e-8a0d-9b3f1e7c5a28" \ -d '{ "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 5000 }' ``` The response is the quote, with its amounts, fees, rate and `expiresAt`, plus agent-specific fields: `destinationChainAddress`, `destinationLightningInvoice` or `destinationSparkAddress` name what it pays, and `minimumReceivingAmount` is what a `CHAIN_ADDRESS` recipient receives. How each destination prices: * **`CHAIN_ADDRESS`**: with `SENDING`, the quote commits to the `USDB` amount; with `RECEIVING`, to exactly what the destination receives, with fees added to the `USDB` sent. Grid pays the chain at a locked rate, so `minimumReceivingAmount` equals `totalReceivingAmount`. If Grid cannot deliver a `RECEIVING` amount exactly, it refuses the quote with `400 EXACT_AMOUNT_UNAVAILABLE`. * **`LIGHTNING_INVOICE`**: `lockedCurrencySide` must be `RECEIVING` and `lockedCurrencyAmount` the invoice's amount in satoshis. Grid pays exactly the invoice and adds the fees to the amount debited. An invoice has one live quote at a time; another quote for it answers `409 INVOICE_ALREADY_USED` unless it repeats the same request with the same `Idempotency-Key`. `403 DESTINATION_NOT_ALLOWED` means Grid will not let the agent pay that destination, for example a Spark address that was refused or is held for review on any of the customer's records of it, or a bank account that is not the customer's own and `ACTIVE`. ## Execute the quote ```bash theme={null} curl -X POST "$GRID_BASE_URL/agents/me/quotes/Quote:019542f5-b3e7-1d02-0000-000000000006/execute" \ -H "Authorization: Bearer $AGENT_TOKEN" \ -H "Idempotency-Key: 0f6d3b8a-4c2e-4a1f-9e7b-2d5c8a1f6e30" ``` Execution returns an `AgentAction`: * **Within policy:** `status: APPROVED` with `transaction` set. Grid signs with the agent's delegated key and the money moves. * **Needs approval:** `status: PENDING_APPROVAL` with no `transaction` yet. This happens when the policy's `defaultExecutionMode` (or the source account's rule) is `APPROVAL_REQUIRED`, or the amount is above the agent's `approvalThresholds` or its spending pool's `approvalThreshold`. Grid sends `AGENT_ACTION.PENDING_APPROVAL` to your platform. * **Refused:** `403` when the policy does not permit the send, a spending limit would be exceeded, the agent has no active delegated key on the wallet, or the destination is no longer allowed. `409` when the quote was already executed, expired, or is in an invalid state. Executing a quote that already has an action returns that action, so the agent can retry safely. Once executed, a send cannot be cancelled. ### Waiting for approval The agent polls `GET /agents/me/actions/{actionId}` (or lists `GET /agents/me/actions?status=PENDING_APPROVAL`) until the status changes. Tell the user in the agent's own words that the customer has been asked. Without `priceTolerance`, a pending send expires with its quote, which is often within minutes. Where Grid enables re-quoting for your platform, the action carries `priceTolerance` and waits until its own `expiresAt`; an approval after the quote expired quotes the send again. A retry of execute with the first quote's id still returns the action after approval replaced its quote. See [Re-quoted sends](/agent-accounts/approvals-and-audit#re-quoted-sends). ## Idempotency keys Agents retry. Send an `Idempotency-Key` (up to 255 characters) on every write so a retry returns the first result instead of moving money twice. | Endpoint | Key | | - | - | | `POST /agents/me/quotes` | Optional | | `POST /agents/me/quotes/{quoteId}/execute` | Optional | | `POST /agents/me/deposits` | Required | | `POST /agents/me/cards` and `.../increase` | Required, scoped to the agent | | `POST /agents/me/claimable-payouts` | Required, scoped to the agent | | `POST /agents/me/payout-runs` | Required, scoped to the agent; the same key with a different body is refused with `409` | | `POST /agents/me/settlement-rules` | Required, scoped to the agent | ## Saved destinations With `MANAGE_EXTERNAL_ACCOUNTS`, the agent manages the customer's external accounts under `/agents/me/external-accounts` (list, add, read, delete). `customerId` is inferred from the agent. Grid saves a Spark address under its network's current prefix (`spark1...`), so a legacy spelling (`sp1...`) of a saved address conflicts with it, and refuses to save a Spark address that was refused or is held for review (`DESTINATION_NOT_ALLOWED`). ## Pay a bank account With `MANAGE_EXTERNAL_ACCOUNTS`, the agent can save a US bank account as one of the customer's external accounts and pay it directly, with the same quote and execute flow as any other send. 1. Save the recipient with `POST /agents/me/external-accounts`, giving `currency: USD` and an `accountInfo` with `accountType: USD_ACCOUNT`, `accountNumber`, `routingNumber`, `bankAccountType`, `bankName` and the `beneficiary` (`beneficiaryType`, `fullName`, `address`). The response is the `ExternalAccount`. `GET /agents/me/external-accounts?currency=USD` lists the ones already saved, and an account that already exists is refused with `409`. Grid screens the account, and only one it cleared becomes `ACTIVE`. 2. Quote the send with `POST /agents/me/quotes`: `destination` is `{ "destinationType": "ACCOUNT", "accountId": "ExternalAccount:..." }`, with the wallet as `source`, `lockedCurrencySide` and `lockedCurrencyAmount`. A destination takes no payment rail. 3. Execute it as in [Execute the quote](#execute-the-quote). The account must be one of the agent's customer's saved bank accounts on your platform and `ACTIVE`. Any other bank account is refused with `403 DESTINATION_NOT_ALLOWED` when the agent quotes or executes. If the account is removed while the action waits for approval, approving it fails the action with `ADDRESS_REFUSED`. A bank payout cannot be called back once it leaves, so above a small amount that Lightspark sets for your platform it waits in `PENDING_APPROVAL` for the customer, whatever the agent's `defaultExecutionMode` and approval thresholds say. At or below that amount the usual rules still apply: `APPROVAL_REQUIRED`, the approval thresholds and the spending pool can each still hold it for approval. A spending pool counts it as a `BANK_PAYOUTS` payment. ## Withdraw to the customer's own bank account Withdrawing is the same send to a bank account the customer owns. The customer's own account is one of the `ACTIVE` USD external accounts that `GET /agents/me/external-accounts?currency=USD` returns, including a bank account your platform validated through Plaid for [bank pulls](/agent-accounts/funding#pull-from-the-customers-bank-account). The agent quotes with that account's `ExternalAccount:...` id as the `ACCOUNT` destination and executes the quote. The same rules apply as for any bank payout: the account must be `ACTIVE`, a payout above the platform's small-payout amount always waits for the customer's approval, and below it the policy and pool rules decide, and `403 DESTINATION_NOT_ALLOWED` means Grid will not pay that account. ## Pay a person by phone or email Where your platform enables claimable payouts and agent bank payouts, an agent can send money to someone with no Grid account using `POST /agents/me/claimable-payouts`. The customer's KYC status must be `APPROVED`. ```bash theme={null} curl -X POST "$GRID_BASE_URL/agents/me/claimable-payouts" \ -H "Authorization: Bearer $AGENT_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 6a9e2c4f-8b1d-4e3a-9f7c-1d5b8e2a6c40" \ -d '{ "sourceAccountId": "InternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123", "amount": 5000, "recipient": { "name": "Maria", "phoneNumber": "+639171234567" }, "note": "Happy birthday, Mom" }' ``` * Exactly one of `phoneNumber` and `email` is set. `country` defaults to the phone number's country and is required with `email`. * Grid holds `amount` against the wallet's balance and the agent's limits from creation; the money stays in the wallet until the recipient claims it. * The payout is checked as a send of the same amount to a bank account. It needs `EXECUTE_QUOTES`, counts toward the spending limits, and waits for approval (`PENDING_APPROVAL`, with a `CLAIMABLE_PAYOUT` action) above the policy's or pool's threshold, in `APPROVAL_REQUIRED` mode, or above your platform's limit for bank payouts without approval. Otherwise it is created `AWAITING_CLAIM`. * Once it is `AWAITING_CLAIM`, your backend issues a claim token with `POST /claimable-payouts/{claimablePayoutId}/claim-token`, puts it in a link to your own claim page, and sends it to the recipient's phone or email. The token is returned once, and issuing another replaces it. Never show it to the agent. The recipient chooses how to be paid from what Grid pays out to in her country. * The claim window ends 7 days after approval. The agent can cancel with `POST /agents/me/claimable-payouts/{claimablePayoutId}/cancel` while it is `PENDING_APPROVAL` or `AWAITING_CLAIM`, which releases the hold. ### Build the claim page Your claim page takes the recipient from the link to payment: **1. Look up the payout** Call `POST /claimable-payouts/claim/lookup` with the token. Show only the sender's name and the amount until the recipient enters a one-time code that you send to the payout's phone number or email. A forwarded link alone must not be enough to claim the payout. **2. List payout options** Call `POST /claimable-payouts/claim/options` to list the currencies and rails Grid pays out to in her country, priced for the payout. **3. Lock a quote** Call `POST /claimable-payouts/claim/quote` with her account details and chosen rail. Grid validates and screens them, saves her as an external account under the sender's customer, and locks a quote. **4. Confirm the payment** Call `POST /claimable-payouts/claim/confirm` to pay her on that quote. The payout becomes `CLAIMED`, then `PAID` or `FAILED`. Because the recipient is saved as an external account, the agent can pay her again later without a link. Statuses are `PENDING_APPROVAL`, `AWAITING_CLAIM`, `CLAIMED`, `PAID`, `EXPIRED`, `CANCELLED` and `FAILED`. Grid sends `CLAIMABLE_PAYOUT.*` webhooks when a payout is claimed, paid, expires or fails. ## Pay many people at once Where your platform enables payout runs and agent payout runs, `POST /agents/me/payout-runs` pays up to 50 rows from the customer's stablecoin Embedded Wallet in one approved action. Each row pays a contact by phone or email (as a claimable payout) or one of the customer's own `ACTIVE` external accounts. * Validation is all or nothing: one row the agent may not send refuses the whole request. * The run is checked against the policy as one payment of `total`, and needs `CREATE_QUOTES` and `EXECUTE_QUOTES`, plus `MANAGE_EXTERNAL_ACCOUNTS` when a row pays a contact. * It runs at once (`PROCESSING`) when the policy allows it without approval; otherwise it waits as a `PAYOUT_RUN` action for up to 24 hours. The customer's one approval covers every row. * Rows succeed or fail one by one. `total` is what leaves the wallet; each row's fees come out of what its recipient receives. * Preview a run with `POST /agents/me/payout-runs/preview` first: it returns `total`, the wallet's free balance after it, and whether and why it would need approval, without creating or holding anything. Grid sends a `PAYOUT_RUN.*` webhook when no row is left to pay. ## Split incoming payments With `MANAGE_SETTLEMENT_RULES`, and where your platform enables settlement rules, an agent can propose a standing rule that splits the customer's incoming payments into their stablecoin Embedded Wallet and pays shares onward (`POST /agents/me/settlement-rules`, previewed with `POST /agents/me/settlement-rules/preview`). A rule outside what the policy sends without approval waits for the customer as a `CREATE_SETTLEMENT_RULE` action for up to seven days. Each share is still checked against the agent's policy when it is paid. ## Reading activity With `VIEW_TRANSACTIONS`, the agent lists the customer's transactions with `GET /agents/me/transactions` (filtered by account, status, type, reference and date) and reads one with `GET /agents/me/transactions/{transactionId}`. Your platform receives the usual `OUTGOING_PAYMENT` and `INCOMING_PAYMENT` webhooks for every send. ## Next steps * [Approvals & audit](/agent-accounts/approvals-and-audit) * [Cards](/agent-accounts/cards) # Sandbox testing Source: https://agents-docs.lightspark.com/agent-accounts/platform-tools/sandbox-testing Run the whole agent account flow in sandbox: create and connect agents, delegate signing, fund the account, approve actions, and simulate card charges The Grid sandbox runs the agent APIs with simulated money. Policies, spending pools, approvals, device codes and webhooks behave as they do in production. Sandbox simulates the API contract, not the banking rails: settlement timings and failure modes that come from a partner bank cannot be reproduced, so test those paths with small amounts in production. ## Sandbox setup 1. Go to [app.lightspark.com](https://app.lightspark.com), create an account, and generate sandbox API keys from the dashboard. 2. Ask your Lightspark contact to enable the Agents API for your sandbox platform, and any of spending pools, agent cards, purchase cards, stored cards, claimable payouts and payout runs you want to test. 3. Use the normal base URL, `https://api.lightspark.com/grid/2025-10-13`, with your sandbox token over HTTP Basic auth. In sandbox, customers are KYC-approved on creation, and a Global Account is provisioned automatically when your platform has `USDB` in its supported currencies. ## End-to-end test **1. Create a customer and find the Global Account** Follow steps 1 and 2 of the [Global Accounts walkthrough](/global-accounts/implementation-overview). **2. Create and connect an agent** `POST /agents` with a policy, then redeem the returned device code with `POST /agents/device-codes/redeem` as the agent software would. Keep the `accessToken` as `$AGENT_TOKEN`. See [Creating agents](/agent-accounts/creating-agents). **3. Delegate signing** Verify the customer's email OTP credential to get a session (the sandbox OTP code is always `000000`), then create the agent's delegated key with `POST /auth/delegated-keys`, stamping each leg with the session key. See [Sandbox testing](/global-accounts/platform-tools/sandbox-testing) for Global Accounts for the OTP and signing shortcuts. **4. Fund the Global Account** ```bash theme={null} curl -X POST "$GRID_BASE_URL/sandbox/internal-accounts/InternalAccount:019542f5-b3e7-1d02-0000-000000000002/fund" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "amount": 100000 }' ``` This credits the account at once and fires the standard `INCOMING_PAYMENT` webhook. **5. Send under and over the threshold** As the agent, quote and execute a send below the policy's `approvalThresholds.amount` and check it comes back `APPROVED`. Then send one above it and check it comes back `PENDING_APPROVAL` and your endpoint receives `AGENT_ACTION.PENDING_APPROVAL`. **6. Approve, reject and let one expire** Approve one pending action and reject another with `POST /agents/{agentId}/actions/{actionId}/approve` and `.../reject`. Leave a third until its quote expires and check it ends `FAILED`. Poll each with `GET /agents/me/actions/{actionId}` as the agent would. **7. Test the edges** Exceed `perTransactionLimit` and check the send is refused with `403`. Pause the agent with `PATCH /agents/{agentId}` and check it can no longer act. Retry a create with the same `Idempotency-Key` and check you get the original back. ## Simulating card charges Where your sandbox platform has a card program, charge an agent's cards with the card simulators. They support card programs where Grid makes authorization decisions. The simulation is accepted with `202` and its result arrives asynchronously, with the usual card webhooks. ```bash theme={null} curl -X POST "$GRID_BASE_URL/sandbox/cards/Card:019542f5-b3e7-1d02-0000-000000000011/simulate/authorization" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "amount": 4999, "currency": { "code": "USD" }, "merchant": { "descriptor": "EXAMPLE STORE", "mcc": "5942", "country": "US" } }' ``` The last three characters of `merchant.descriptor` (1 to 25 characters) choose the outcome: | Suffix | Outcome | | - | - | | `002` | Declined, `INSUFFICIENT_FUNDS` | | `003` | Declined, `CARD_NOT_ACTIVE`; use against a `FROZEN` card | | `005` | Delayed pull (about 30 seconds), exercising `PENDING` to `CONFIRMED` | | `006` | Pull succeeds but the confirmation reports `FAILED` | | Anything else | Approved | Use them to check how your product shows an agent's card charges and declines, including charges above the agent's limits or spending pool. The other simulators under `/sandbox/cards/{id}/simulate/...` cover clearing, returns, reversals and disputes. ## Webhooks All webhooks fire in sandbox. Use `POST /webhooks/test` to send a signed test event to your endpoint, and see [Webhooks](/agent-accounts/platform-tools/webhooks) for the agent events. > [!WARNING] > > Do not send real money to sandbox addresses, invoices or accounts. They are not real destinations and will not receive funds. ## Moving to production 1. Swap in production API tokens and ask your Lightspark contact to enable the agent features you use on your production platform. 2. Remove sandbox shortcuts, such as the fixed OTP code, from your client and server code. 3. Configure production webhook endpoints and confirm `AGENT_ACTION.PENDING_APPROVAL` reaches the customer as a notification. 4. Start agents with conservative policies and small limits. # Webhooks Source: https://agents-docs.lightspark.com/agent-accounts/platform-tools/webhooks Receive agent action, payout and payment webhooks for agent accounts, and verify their signatures Grid tells your backend about agent activity with the same signed webhooks it uses for everything else. Configure your endpoint in the dashboard, then handle the events below. ## Agent events | Event | Sent when | Payload | | - | - | - | | `AGENT_ACTION.PENDING_APPROVAL` | An agent's request needs the customer's approval | The full `AgentAction`, including the quote, purchase card, stored card use, payout, payout run or settlement rule it is for | | `AGENT_ACTION.APPROVED` | An action was approved, including a stored card use Grid auto-approved because the customer chose to | The `AgentAction` | | `AGENT_ACTION.REJECTED` | An action was rejected | The `AgentAction`, with `rejectionReason` when one was given | | `AGENT_ACTION.FAILED` | An action was not executed, or execution failed | The `AgentAction`, with `failureReason` | | `CLAIMABLE_PAYOUT.CLAIMED`, `.PAID`, `.EXPIRED`, `.FAILED` | A claimable payout was claimed, paid, expired or failed. `EXPIRED` and `FAILED` mean the hold on the sender's wallet was released | The `ClaimablePayout` | | `PAYOUT_RUN.COMPLETED`, `.PARTIALLY_FAILED`, `.FAILED` | No row of a payout run is left to pay. A cancelled run sends none | The run with its row counts | | `SETTLEMENT_RULE.EXECUTED`, `.FAILED`, `.SKIPPED` | A settlement rule's execution on an incoming payment finished | The execution | The money itself moves with the standard `INCOMING_PAYMENT` and `OUTGOING_PAYMENT` webhooks, and agent cards send the same card status and card transaction webhooks as any card. ### Pending approval example ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000021", "type": "AGENT_ACTION.PENDING_APPROVAL", "timestamp": "2026-10-26T15:00:00Z", "data": { "id": "AgentAction:019542f5-b3e7-1d02-0000-000000000098", "agentId": "Agent:019542f5-b3e7-1d02-0000-000000000042", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000010", "platformCustomerId": "user-a1b2c3", "status": "PENDING_APPROVAL", "type": "ISSUE_PURCHASE_CARD", "purchaseCard": { "amount": 12000, "spendLimit": 13000, "currency": "USD", "description": "Concert tickets from example-tickets.com", "fundingSource": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" }, "expiresAt": "2026-10-26T15:15:00Z", "createdAt": "2026-10-26T15:00:00Z", "updatedAt": "2026-10-26T15:00:00Z" } } ``` ### Handling agent webhooks * Respond `200` quickly and do the work asynchronously. A webhook whose `id` you have already processed can be acknowledged and skipped. * On `AGENT_ACTION.PENDING_APPROVAL`, notify the customer immediately. A send without `priceTolerance` expires with its quote, often within minutes. * Don't rely on webhooks alone for the approval queue. `GET /agents/approvals` rebuilds it if your endpoint was down. * Send a signed test event to your endpoint at any time with `POST /webhooks/test`. All webhooks sent by the Grid API include a signature in the `X-Grid-Signature` header, which allows you to verify the authenticity of the webhook. This is critical for security, as it ensures that only legitimate webhooks from Grid are processed by your system. ## Signature Verification Process 1. **Obtain your Grid public key** * This is available in your [dashboard](https://app.lightspark.com) under Developers webhook settings. * The key is in PEM format and can be used with standard cryptographic libraries 2. **Verify incoming webhooks** * Extract the signature from the `X-Grid-Signature` header * Decode the base64 signature * Create a SHA-256 hash of the entire request body * Verify the signature using the Grid webhook public key and the hash * Only process the webhook if the signature verification succeeds ## Verification Examples ### Node.js Example ```javascript theme={null} const crypto = require('crypto'); const express = require('express'); const app = express(); // Your Grid public key provided during integration const GRID_WEBHOOK_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE... -----END PUBLIC KEY-----`; app.post('/webhooks/uma', (req, res) => { const signatureHeader = req.header('X-Grid-Signature'); if (!signatureHeader) { return res.status(401).json({ error: 'Signature missing' }); } try { let signature: Buffer; try { // Parse the signature as JSON. It's in the format {"v": "1", "s": "base64_signature"} const signatureObj = JSON.parse(signatureHeader); if (signatureObj.v && signatureObj.s) { // The signature is in the 's' field signature = Buffer.from(signatureObj.s, "base64"); } else { throw new Error("Invalid JSON signature format"); } } catch { // If JSON parsing fails, treat as direct base64 signature = Buffer.from(signatureHeader, "base64"); } // Create verifier with the public key and correct algorithm const verifier = crypto.createVerify("SHA256"); const payload = await request.text(); verifier.update(payload); verifier.end(); // Verify the signature using the webhook public key const isValid = verifier.verify( { key: GRID_WEBHOOK_PUBLIC_KEY, format: "pem", type: "spki", }, signature, ); if (!isValid) { return res.status(401).json({ error: 'Invalid signature' }); } // Webhook is verified, process it based on type const webhookData = req.body; if (webhookData.type.startsWith('INCOMING_PAYMENT.')) { // Process incoming payment webhook // ... } else if (webhookData.type.startsWith('OUTGOING_PAYMENT.')) { // Process outgoing payment webhook // ... } // Acknowledge receipt of the webhook return res.status(200).json({ received: true }); } catch (error) { console.error('Signature verification error:', error); return res.status(401).json({ error: 'Signature verification failed' }); } }); app.listen(3000, () => { console.log('Webhook server listening on port 3000'); }); ``` ### Python Example ```python theme={null} from cryptography.hazmat.primitives import serialization, hashes from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature from flask import Flask, request, jsonify import base64 app = Flask(__name__) # Your Grid public key provided during integration GRID_PUBLIC_KEY = """-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE... -----END PUBLIC KEY-----""" # Load the public key public_key = serialization.load_pem_public_key( GRID_PUBLIC_KEY.encode('utf-8') ) @app.route('/webhooks/uma', methods=['POST']) def handle_webhook(): # Get signature from header signature = request.headers.get('X-Grid-Signature') if not signature: return jsonify({'error': 'Signature missing'}), 401 try: # Get the raw request body request_body = request.get_data() # Create a SHA-256 hash of the request body hash_obj = hashes.Hash(hashes.SHA256()) hash_obj.update(request_body) digest = hash_obj.finalize() # Decode the base64 signature signature_bytes = base64.b64decode(signature) # Verify the signature try: public_key.verify( signature_bytes, request_body, ec.ECDSA(hashes.SHA256()) ) except Exception as e: return jsonify({'error': 'Invalid signature'}), 401 # Webhook is verified, process it based on type webhook_data = request.json if webhook_data['type'].startswith('INCOMING_PAYMENT.'): # Process incoming payment webhook # ... pass elif webhook_data['type'].startswith('OUTGOING_PAYMENT.'): # Process outgoing payment webhook # ... pass # Acknowledge receipt of the webhook return jsonify({'received': True}), 200 except Exception as e: print(f'Signature verification error: {e}') return jsonify({'error': 'Signature verification failed'}), 401 if __name__ == '__main__': app.run(port=3000) ``` ## Testing To test your webhook implementation, you can trigger a test webhook from the Grid dashboard. This will send a test webhook to the endpoint you provided during the integration process. The test webhook will also be sent automatically when you update your platform configuration with a new webhook URL. An example of the test webhook payload is shown below: ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000007", "type": "TEST", "timestamp": "2025-08-15T14:32:00Z", "data": {} } ``` You should verify the signature of the webhook using the Grid public key and the process outlined in the [Signature Verification Process](#signature-verification-process) section and then reply with a 200 OK response to acknowledge receipt of the webhook. ## Security Considerations * **Always verify signatures**: Never process webhooks without verifying their signatures. * **Use HTTPS**: Ensure your webhook endpoint uses HTTPS to prevent man-in-the-middle attacks. * **Implement idempotency**: Use the `id` field to prevent processing duplicate webhooks. * **Timeout handling**: Implement proper timeout handling and respond to webhooks promptly. ## Retry Policy The Grid API will retry webhooks with the following policy based on the webhook type: | Webhook Type | Retry Policy | Notes | | - | - | - | | `TEST` | No retries | Used for testing webhook configuration | | `OUTGOING_PAYMENT.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `INCOMING_PAYMENT.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on: 409 (duplicate webhook) or PENDING status since it is served as an approval mechanism in-flow | | `BULK_UPLOAD.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `WALLET_OPERATION.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `INVITATION.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CUSTOMER.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `ACCOUNT.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CARD.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CARD_TRANSACTION.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CARD_AUTHORIZATION.REQUEST` | No retries | Real-time decision request, sent only while `cardConfigs.realTimeAuthorization` is set | # Policies & permissions Source: https://agents-docs.lightspark.com/agent-accounts/policies-and-permissions Bound what an agent can do with permissions, execution modes, spending limits, account restrictions and approval thresholds. > [!NOTE] > > The Agents API must be enabled for your platform; until it is, agent endpoints answer `403`. To enable it, [book a demo](https://www.lightspark.com/contact) or reach out to your Lightspark contact. Every agent has one policy. Grid evaluates it on every request the agent makes, so your platform does not need its own delegated-access model. Let customers see and change the policy in your product; Grid enforces whatever it says. A policy answers four questions: 1. What may the agent do? (`permissions`) 2. Does it act on its own or ask first? (`defaultExecutionMode`, `approvalThresholds`) 3. How much may it spend? (`spendingLimits`) 4. Which accounts may it use? (`accountRestrictions`) ## Example policy ```json theme={null} { "permissions": ["VIEW_TRANSACTIONS", "CREATE_QUOTES", "EXECUTE_QUOTES", "MANAGE_CARDS", "ISSUE_CARDS"], "defaultExecutionMode": "AUTO", "spendingLimits": { "currency": "USD", "perTransactionLimit": 50000, "dailyLimit": 200000, "dailyTransactionLimit": 10, "monthlyLimit": 1000000 }, "approvalThresholds": { "currency": "USD", "amount": 2500 }, "accountRestrictions": { "allowedAccountIds": ["InternalAccount:019542f5-b3e7-1d02-0000-000000000002"] } } ``` With this policy the agent sends and buys on its own up to $25.00, asks the customer above that, and is refused above $500.00 a payment, $2,000.00 a day, ten payments a day, or $10,000.00 a month. > [!NOTE] > > All amounts are integers in the smallest unit of the policy currency: `50000` with `"currency": "USD"` is \$500.00. When a payment is in another currency, Grid converts it to the policy currency at evaluation time. ## Permissions Grant the smallest set the agent needs. Permissions are an allowlist: anything not granted is refused with `403`. | Permission | What it allows | | - | - | | `VIEW_TRANSACTIONS` | List and read transactions and account balances | | `CREATE_QUOTES` | Create quotes | | `EXECUTE_QUOTES` | Execute quotes, and send claimable payouts | | `MANAGE_EXTERNAL_ACCOUNTS` | Create and manage the customer's external accounts | | `RECEIVE_FUNDS` | Create deposit targets (a Lightning invoice or a stablecoin address) | | `MANAGE_IDENTITY` | Read and update the customer's identity details and start KYC verification | | `MANAGE_CARDS` | Read its cards, tighten their merchant categories, freeze them, and read a purchase card's details | | `ISSUE_CARDS` | Issue one standard virtual card for itself while it holds no live standard card, and single-use and recurring purchase cards where your platform enables them | | `USE_STORED_CARDS` | List the customer's stored cards it is allowed to use, and ask for a single-use link to one's details. Never granted by default | | `MANAGE_SETTLEMENT_RULES` | Propose rules that split the customer's incoming payments, read and delete the rules it created, and cancel their held executions | Some operations need more than one permission. Payout runs need `CREATE_QUOTES` and `EXECUTE_QUOTES`, and `MANAGE_EXTERNAL_ACCOUNTS` when a row pays a contact. Increasing a purchase card needs `ISSUE_CARDS` and `MANAGE_CARDS`. ## Execution modes `defaultExecutionMode` decides what happens to a payment the policy allows: * `AUTO`: Grid executes it at once. * `APPROVAL_REQUIRED`: Grid creates an `AgentAction` in `PENDING_APPROVAL` and waits for your platform to approve or reject it. Even in `AUTO`, a payment above `approvalThresholds.amount` waits for approval. So does a payment above the approval threshold of the agent's [spending pool](/agent-accounts/spending-limits). Some requests ask regardless of mode: a recurring purchase card always does, and a use of the customer's stored card does unless the customer chose to auto-approve that merchant (one that needs the security code always does). See [Approvals & audit](/agent-accounts/approvals-and-audit). ## Spending limits `spendingLimits` caps the agent's sends and the purchases on cards issued for it. | Field | Required | Meaning | | - | - | - | | `currency` | Yes | ISO 4217 code every amount is in. Immutable after the agent is created; changing it returns `400`. | | `perTransactionLimit` | Yes | Largest single send or card authorization | | `dailyLimit` | No | Most the agent may send and spend on its cards per day. `null` for no daily limit | | `dailyTransactionLimit` | No | Most sends and card authorizations per day | | `monthlyLimit` | No | Most the agent may send and spend on its cards per month. `null` for no monthly limit | `GET /agents/{agentId}` and the agent's own `GET /agents/me` return `usage` with the agent's running counters: `dailyTransactionCount`, `dailySpend`, `monthlySpend`, and when the daily and monthly counters reset (`dailyResetDate`, `monthlyResetMonth`). Show these next to the limits so the customer can see how much room is left. A payment over a limit is refused, not held for approval. Since a payment above `perTransactionLimit` is always refused, set `approvalThresholds.amount` below it for the threshold to ever ask. ## Approval thresholds `approvalThresholds` forces approval for larger payments even when `defaultExecutionMode` is `AUTO`. | Field | Meaning | | - | - | | `currency` | Must equal `spendingLimits.currency`. Required when `amount` is set. | | `amount` | Any payment above this amount requires approval. `null` for no threshold. | ## Account restrictions By default an agent can use all of the customer's internal accounts. Restrict it with `accountRestrictions`: * `allowedAccountIds`: the internal accounts the agent may use. `null` means all of them. * `accountRules`: per-account overrides. Each rule names an `accountId` and may set its own `executionMode` and `perTransactionLimit` (in the policy currency; `null` inherits the agent's limit). ```json theme={null} { "accountRestrictions": { "allowedAccountIds": [ "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "InternalAccount:019542f5-b3e7-1d02-0000-000000000003" ], "accountRules": [ { "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000003", "executionMode": "APPROVAL_REQUIRED", "perTransactionLimit": 10000 } ] } } ``` The agent can discover the accounts available to it with `GET /agents/me/internal-accounts`. ## Updating a policy `PATCH /agents/{agentId}/policy` takes a partial policy. Omitted fields keep their values, and the change takes effect immediately. ```bash theme={null} curl -X PATCH "$GRID_BASE_URL/agents/Agent:019542f5-b3e7-1d02-0000-000000000001/policy" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "approvalThresholds": { "currency": "USD", "amount": 10000 }, "spendingLimits": { "dailyLimit": 300000 } }' ``` * `permissions`, when sent, replaces the whole list. * Within `spendingLimits`, only the fields you send change. `dailyLimit` and `monthlyLimit` accept `null` to remove them. `currency` may be sent only with its current value. * The response is the updated `Agent`. A policy change applies to decisions made after it. An action already waiting for approval is checked against the policy as it is when it is approved, so lowering a limit can make a pending action fail with `POLICY_DENIED`. ## Practical guidance * Start new agents with the fewest permissions and `APPROVAL_REQUIRED`, and let the customer loosen them. * Keep read permissions (`VIEW_TRANSACTIONS`) separate from money movement (`EXECUTE_QUOTES`, `ISSUE_CARDS`). * Grant `MANAGE_EXTERNAL_ACCOUNTS` only when the agent needs to add destinations; a new destination is where most mistakes and most fraud start. * Grant `USE_STORED_CARDS` only after the customer has explicitly chosen to share a card with this agent. * Show the current policy and usage in your product, and confirm policy changes with the customer. ## Next steps * [Spending limits](/agent-accounts/spending-limits): one budget shared by several agents * [Approvals & audit](/agent-accounts/approvals-and-audit): what happens when a payment needs the customer # Spending limits Source: https://agents-docs.lightspark.com/agent-accounts/spending-limits Hold several of a customer's agents to one shared budget with spending pools, on top of each agent's own policy. > [!NOTE] > > The Agents API must be enabled for your platform, and spending pools are enabled per platform on top of it; until they are, the spending pool endpoints answer `403`. To enable them, [book a demo](https://www.lightspark.com/contact) or reach out to your Lightspark contact. Each agent's [policy](/agent-accounts/policies-and-permissions) limits that agent alone. A spending pool limits a group of a customer's agents together: a team budget, a project allowance, or a cap on everything the customer's agents spend on cards this month. A pool holds no money and no money moves between pools. It only limits what its agents may spend from the customer's balance, in addition to each agent's own policy. Limits of different pools may add up to more than the balance, and each agent belongs to at most one pool. ## How a pool decides Every agent payment checks the pool: sends (including Lightning), bank payouts, and card authorizations. A payment must fit both the agent's own policy and its pool. * **Payment type.** A payment whose type is not in `allowedPaymentTypes` is refused. * **Limits.** A payment that would take the pool over `limitAmount`, `perTransactionLimit`, `dailyLimit` or `monthlyLimit` is refused. * **Approval.** A payment above the pool's `approvalThreshold` waits for the customer's approval, even when the agent's own policy would execute it at once. Card authorizations are never held for approval. A refused payment is rejected with `AGENT_POLICY_DENIED` and `details.policyDenialReason` set to `POOL_LIMIT_EXCEEDED` or `POOL_PAYMENT_TYPE_NOT_ALLOWED`; a refused card authorization is declined. Payments in flight hold their amount against the pool until they complete or fail, so concurrent payments cannot together exceed it. ### Payment types | Value | Covers | | - | - | | `CARDS` | Authorizations on the agents' cards | | `CRYPTO_SENDS` | Sends to crypto destinations, Spark and Lightning included | | `BANK_PAYOUTS` | Payouts to bank accounts | ### Limit types | `limitType` | `limitAmount` means | | - | - | | `RECURRING` | The most the pool's agents may spend together per `period`: `MONTH` (UTC calendar month) or `WEEK` (starting Monday 00:00 UTC). Resets at the start of each period. | | `TOTAL` | A running total that never resets. Raise `limitAmount` to allow more. | `dailyLimit` (per UTC day) and `monthlyLimit` (per UTC calendar month) are optional caps on top of `limitAmount`. `perTransactionLimit` caps a single payment. ## Create a pool `POST /spending-pools` with platform credentials. `customerId`, `name`, `limitType`, `limitAmount` and `allowedPaymentTypes` are required; `period` is required with `RECURRING` and rejected with `TOTAL`. `currency` defaults to `USD`. ```bash theme={null} curl -X POST "$GRID_BASE_URL/spending-pools" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 7d1f3c2e-8a4b-4f6e-9c0d-2b5a7e9f1c3d" \ -d '{ "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "name": "Marketing", "currency": "USD", "limitType": "RECURRING", "period": "MONTH", "limitAmount": 500000, "perTransactionLimit": 50000, "approvalThreshold": 25000, "dailyLimit": 100000, "allowedPaymentTypes": ["CARDS", "CRYPTO_SENDS"] }' ``` **Response:** `201 Created` ```json theme={null} { "id": "SpendingPool:019542f5-b3e7-1d02-0000-000000000007", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "name": "Marketing", "currency": "USD", "limitType": "RECURRING", "period": "MONTH", "limitAmount": 500000, "perTransactionLimit": 50000, "approvalThreshold": 25000, "dailyLimit": 100000, "allowedPaymentTypes": ["CARDS", "CRYPTO_SENDS"], "requiresAdminApproval": false, "isArchived": false, "usage": { "periodStart": "2026-11-01", "periodEnd": "2026-12-01", "spent": 0, "remaining": 100000, "dailySpent": 0, "monthlySpent": 0 }, "createdAt": "2026-11-02T17:32:28Z", "updatedAt": "2026-11-02T17:32:28Z" } ``` `usage.remaining` is the least room left under `limitAmount`, `dailyLimit` and `monthlyLimit`, never below zero, counting payments still in flight. Here it is `100000` because the daily cap is the tightest. `requiresAdminApproval` is stored for your platform: set it when your product requires an administrator, rather than the agent's own user, to approve the pool's payments that need approval. Grid does not act on it. ### Idempotent creation `Idempotency-Key` is optional; send one so a retry cannot create a second pool. Keys are scoped to the customer. * A retry with a key already used returns the pool the first request created, with `200` instead of `201`. * The retry is compared with the pool as it is now. Reusing a key with a different name, currency, limit type, period, limit amount, per-transaction limit, approval threshold, daily or monthly limit, allowed payment types or `requiresAdminApproval`, after the pool was changed, or for a pool that was archived, is rejected with `409`. * The same key on another customer creates a separate pool. ## Assign agents Put an agent in a pool when you create it (`spendingPoolId` on `POST /agents`), or move it later: ```bash theme={null} curl -X PATCH "$GRID_BASE_URL/agents/Agent:019542f5-b3e7-1d02-0000-000000000001" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "spendingPoolId": "SpendingPool:019542f5-b3e7-1d02-0000-000000000007" }' ``` Send `"spendingPoolId": null` to take the agent out of its pool. The pool must belong to the agent's customer, and the agent's policy must be in the pool's currency. ## Read, change and archive | Task | Call | | - | - | | Read a pool with its current usage | `GET /spending-pools/{spendingPoolId}` | | List pools | `GET /spending-pools`, filtered by `customerId` and `isArchived`; `limit` (default 20, max 100) and `cursor` | | Change name, limits, payment types or `requiresAdminApproval` | `PATCH /spending-pools/{spendingPoolId}` with any of those fields; set a cap or `approvalThreshold` to `null` to remove it | | Archive | `DELETE /spending-pools/{spendingPoolId}`, refused with `409` while any agent is assigned | A change applies to payments decided after it. Lowering a limit below what is already spent refuses further payments; it never reverses one. Changing `limitType` to `RECURRING` requires `period`, and changing it to `TOTAL` removes `period`. An archived pool takes no agents, cannot be changed (`409`), and is still returned by `GET`. ## Next steps * [Policies & permissions](/agent-accounts/policies-and-permissions): each agent's own limits * [Approvals & audit](/agent-accounts/approvals-and-audit): what happens to a payment above the pool's threshold # Approve an agent action Source: https://agents-docs.lightspark.com/api-reference/agent-management/approve-an-agent-action /openapi.yaml post /agents/{agentId}/actions/{actionId}/approve Approve a pending agent action, allowing Grid to proceed with execution. The action must have status `PENDING_APPROVAL`. Once approved, Grid executes the underlying operation (quote execution or transfer) and the action transitions to `APPROVED`. For an `EXECUTE_QUOTE` action without `priceTolerance`, note that the underlying quote may have expired between submission and approval — in that case the action will transition to `FAILED` instead. An `EXECUTE_QUOTE` action with `priceTolerance` whose quote is still live executes that quote, as shown. Once its quote has expired, approval quotes the send again and runs every check against the new quote and its amount. When the new price is no worse for the customer than `priceTolerance` allows, Grid executes the new quote and the action transitions to `APPROVED` with it as `quote`. When it is worse by more, nothing is sent: the response is `200` with status still `PENDING_APPROVAL`, the new `quote`, and `previousQuote` set to the quote the customer was shown, so they can be asked again; approving again repeats the check against the new quote. A send Grid can no longer quote fails with the matching `failureReason`, and one past its `expiresAt` with `APPROVAL_EXPIRED`. For `ISSUE_PURCHASE_CARD` actions, approval issues the purchase card and the action carries its `cardId`. When `purchaseCard.increaseCardId` is set, approval instead raises that existing card to the new amount and limit. The card's limit is checked against the agent's policy again first; an action past its `expiresAt` fails with `APPROVAL_EXPIRED`, one the policy now denies with `POLICY_DENIED`, a paused or deleted agent with `AGENT_INACTIVE`, and one the card issuer refuses with `EXECUTION_FAILED`. For `USE_STORED_CARD` actions, approval lets the agent collect one single-use link to the card's details. A use with `storedCardUse.needsCvc` needs `storedCardCvc`, the security code encrypted by Evervault's Card component; without it the request is refused with `400`. `autoApproveMerchant` also lets the agent use the card at the same merchant again, up to the same amount, without asking. Grid cannot verify the customer here: confirm the decision with them (for example with a passkey or an emailed code) before calling. An action past its `expiresAt` fails with `APPROVAL_EXPIRED`, and one whose card was removed, or whose agent lost access or is paused or deleted, with `AGENT_INACTIVE`. This endpoint is called by the platform's backend using platform credentials, not by the agent itself. # Archive a spending pool Source: https://agents-docs.lightspark.com/api-reference/agent-management/archive-a-spending-pool /openapi.yaml delete /spending-pools/{spendingPoolId} Archive a spending pool. Refused with `409` while any agent is assigned to it; move or delete those agents first. An archived pool is still returned by `GET /spending-pools/{spendingPoolId}`. # Create a spending pool Source: https://agents-docs.lightspark.com/api-reference/agent-management/create-a-spending-pool /openapi.yaml post /spending-pools Create a spending pool on a customer. Agents assigned to it (with `spendingPoolId` on `POST /agents` or `PATCH /agents/{agentId}`) are held to its limits and allowed payment types together, in addition to each agent's own policy. Available where the platform enables spending pools. Every agent payment checks the pool: sends (including Lightning), bank payouts and card authorizations. A payment the pool refuses is rejected with `AGENT_POLICY_DENIED` and `details.policyDenialReason` `POOL_LIMIT_EXCEEDED` or `POOL_PAYMENT_TYPE_NOT_ALLOWED`; a card authorization is declined. Payments in flight hold their amount against the pool until they complete or fail, so concurrent payments cannot together exceed it. `RECURRING` limits reset on UTC calendar boundaries. Send an `Idempotency-Key` header to make retries safe: a request carrying a key already used returns the pool the first request created, with a `200` instead of a `201`. The request is compared with the pool as it is now, so reusing a key with a different name, currency, limit type, period, limit amount, per-transaction limit, approval threshold, daily limit, monthly limit, allowed payment types or `requiresAdminApproval`, after the pool was changed, or for a pool that was archived is rejected with `409`. Without a key a retry cannot be recognised, so each request creates its own pool. Keys are scoped to the customer, so the same key on another customer creates a separate pool. # Create an agent Source: https://agents-docs.lightspark.com/api-reference/agent-management/create-an-agent /openapi.yaml post /agents Create a new agent with a specified policy. Returns the created agent and a device code that must be redeemed by the agent software to complete installation. Agents of a `BUSINESS` customer are available only where the platform enables them, and require `actingFor` to name the person the agent acts for. Until the business's KYB status is `APPROVED`, its agents' sends and deposits are held to the same limits as an unverified individual's, counted across the whole business. Send an `Idempotency-Key` header to make retries safe: a request carrying a key already used returns the agent the first request created, with a `200` instead of a `201` and a new device code that replaces any unredeemed one. The request is compared with the agent as it is now, so reusing a key with a different customer, name, policy, `actingFor` or `spendingPoolId`, after the agent's policy or pool was changed, for an agent that was deleted, or for one already connected is rejected with `409`. Without a key a retry cannot be recognised, so each request creates its own agent. # Delete agent Source: https://agents-docs.lightspark.com/api-reference/agent-management/delete-agent /openapi.yaml delete /agents/{agentId} Permanently delete an agent. Connected agent software will lose access immediately. # Get a spending pool Source: https://agents-docs.lightspark.com/api-reference/agent-management/get-a-spending-pool /openapi.yaml get /spending-pools/{spendingPoolId} Retrieve a spending pool with what its agents have spent and may still spend in the current period. # Get a stored card Source: https://agents-docs.lightspark.com/api-reference/agent-management/get-a-stored-card /openapi.yaml get /customers/{customerId}/stored-cards/{storedCardId} Retrieve one of the customer's stored cards, including a `REMOVED` one. # Get agent by ID Source: https://agents-docs.lightspark.com/api-reference/agent-management/get-agent-by-id /openapi.yaml get /agents/{agentId} Retrieve an agent by its system-generated ID. # Get device code status Source: https://agents-docs.lightspark.com/api-reference/agent-management/get-device-code-status /openapi.yaml post /agents/device-codes/status Check the status of a device code. Use this to poll for agent installation completion after creating an agent. # Hand a deleted agent's card to an agent Source: https://agents-docs.lightspark.com/api-reference/agent-management/hand-a-deleted-agents-card-to-an-agent /openapi.yaml post /agents/{agentId}/card-handover Move the customer's standard agent card that Grid froze when the agent it was issued for was deleted to this agent, and unfreeze it. Use this when a customer reconnects agent software whose agent the platform deleted: a frozen card still counts against the customer's card limit, so without a hand-over the customer can neither use it nor be issued another. A card qualifies only while the freeze Grid placed on its agent's deletion is the only reason it is frozen. A card frozen by the platform, the customer, an agent or Lightspark is left as it is, and single-use purchase cards are never handed over. With `kind=PURCHASE`, the newest qualifying recurring purchase card is handed over instead; call again until `204` to move each of them. It is available only where the platform enables recurring purchase cards. The agent must also hold an active delegated key on the Embedded Wallet that funds the card, which pays for the card from then on; a card on a wallet where it holds none is not handed over. The agent may hold a standard card, but must have room under the platform's limit on open recurring purchase cards, counting those awaiting approval; otherwise the request is rejected with `PURCHASE_CARD_LIMIT_REACHED`. The agent must belong to the card's customer, must be neither paused nor deleted, and must have a policy granting `MANAGE_CARDS`. For a standard card it must also hold no standard card that is not `CLOSED`. When several cards qualify, the most recently issued one is handed over. The customer must still meet what issuing a card requires: an individual customer with approved KYC who has accepted the cardholder agreement. The card is unfrozen as a platform `status: ACTIVE` update is, so the platform receives the same card status webhook; a card whose funding authorization is missing comes back `PENDING_AUTH`. A card that was still being provisioned when its agent was deleted is not handed over. Responds `204` when the customer has no card to hand over. # Let an agent use a stored card Source: https://agents-docs.lightspark.com/api-reference/agent-management/let-an-agent-use-a-stored-card /openapi.yaml put /customers/{customerId}/stored-cards/{storedCardId}/agents/{agentId} Let one of the customer's own agents use this card. The agent also needs the `USE_STORED_CARDS` permission, which is never granted by default. Every use still waits for the customer's approval unless they choose to auto-approve a merchant. Granting access is a grant of spending power on the customer's own card: confirm it with the customer (for example with a passkey or an emailed code) before calling. Granting an agent that already has access succeeds and changes nothing. # List agent transaction approval requests Source: https://agents-docs.lightspark.com/api-reference/agent-management/list-agent-transaction-approval-requests /openapi.yaml get /agents/approvals Retrieve a paginated list of agent actions that require platform approval. Filter by `agentId` or `customerId` to scope results to a specific agent or customer. Approve or reject individual actions via `POST /agents/{agentId}/actions/{actionId}/approve` or `POST /agents/{agentId}/actions/{actionId}/reject`. # List agents Source: https://agents-docs.lightspark.com/api-reference/agent-management/list-agents /openapi.yaml get /agents Retrieve a paginated list of agents for the authenticated platform. # List spending pools Source: https://agents-docs.lightspark.com/api-reference/agent-management/list-spending-pools /openapi.yaml get /spending-pools Retrieve a paginated list of the platform's spending pools, with their current usage. # List stored cards Source: https://agents-docs.lightspark.com/api-reference/agent-management/list-stored-cards /openapi.yaml get /customers/{customerId}/stored-cards The customer's `ACTIVE` stored cards, newest first. # Redeem device code Source: https://agents-docs.lightspark.com/api-reference/agent-management/redeem-device-code /openapi.yaml post /agents/device-codes/redeem Redeem a device code to obtain agent credentials. This endpoint is called by the agent software during installation. On success, returns a Bearer access token that the agent uses for all subsequent API calls. The token is returned only once and must be stored securely. This endpoint does not require platform authentication — the device code itself serves as proof of authorization. A code can be redeemed only once and only before its `expiresAt`. Redemption is rate limited. A caller over the limit receives `429` with `RATE_LIMITED` and a `Retry-After` header, and should wait that long before retrying. If the limit cannot be checked, the call is also rejected with `429`. # Regenerate a device code Source: https://agents-docs.lightspark.com/api-reference/agent-management/regenerate-a-device-code /openapi.yaml post /agents/{agentId}/device-codes Generate a new device code for an existing agent. Use this when the original device code has expired before being redeemed, or when the agent software needs to be reinstalled. An agent has at most one unredeemed device code, so regenerating invalidates the one issued before it. # Reject an agent action Source: https://agents-docs.lightspark.com/api-reference/agent-management/reject-an-agent-action /openapi.yaml post /agents/{agentId}/actions/{actionId}/reject Reject a pending agent action, preventing execution. The action must have status `PENDING_APPROVAL`. Once rejected, the action transitions to `REJECTED` and the underlying operation is not executed. This endpoint is called by the platform's backend using platform credentials, not by the agent itself. # Remove a stored card Source: https://agents-docs.lightspark.com/api-reference/agent-management/remove-a-stored-card /openapi.yaml delete /customers/{customerId}/stored-cards/{storedCardId} Remove a stored card. Grid erases its encrypted number, holder name and billing address at once, so the card cannot be recovered; every agent loses access, links not yet opened stop working, and uses awaiting approval fail. The card stays readable with its display fields and status `REMOVED`. Removing a removed card succeeds and changes nothing. # Reveal a stored card to its owner Source: https://agents-docs.lightspark.com/api-reference/agent-management/reveal-a-stored-card-to-its-owner /openapi.yaml post /customers/{customerId}/stored-cards/{storedCardId}/reveal Issues a single-use link, valid for two minutes, that answers the card's number, expiry and holder name to the customer who owns the card. Call it only after the platform has verified the customer is present (for example with a fresh step-up), and hand the link only to that customer's browser, for Evervault's Reveal component to open: the link goes through Evervault's Relay, which decrypts the number on the way, so whoever opens it receives the card number in plaintext. The link never answers a security code. Only an `ACTIVE` card can be revealed. Each issue and each opening is recorded in the card's audit log with the customer as the actor. # Stop an agent using a stored card Source: https://agents-docs.lightspark.com/api-reference/agent-management/stop-an-agent-using-a-stored-card /openapi.yaml delete /customers/{customerId}/stored-cards/{storedCardId}/agents/{agentId} Take the agent's access to this card away, with its auto-approved merchants. Its links not yet opened stop working and its uses awaiting approval fail. Removing access the agent does not have succeeds and changes nothing. # Store a card Source: https://agents-docs.lightspark.com/api-reference/agent-management/store-a-card /openapi.yaml post /customers/{customerId}/stored-cards Store a card the customer already owns so the agents they choose can use it. Collect the card with Evervault's Card UI component and send its ciphertext: a number or security code that is not Evervault ciphertext is refused with `400 INVALID_INPUT` and never stored or logged. Grid verifies the card with its issuer before storing it: the security code, billing address and name. A card the issuer does not confirm is refused with `400 STORED_CARD_VERIFICATION_FAILED`. The security code is then discarded; Grid never stores it. The new card is shared with no agent: add one with `PUT /customers/{customerId}/stored-cards/{storedCardId}/agents/{agentId}`. Available where the platform enables stored cards, for individual customers. # Update a spending pool Source: https://agents-docs.lightspark.com/api-reference/agent-management/update-a-spending-pool /openapi.yaml patch /spending-pools/{spendingPoolId} Change a spending pool's name, limits, allowed payment types or admin-approval flag. The change applies to payments decided after it. # Update agent Source: https://agents-docs.lightspark.com/api-reference/agent-management/update-agent /openapi.yaml patch /agents/{agentId} Update an agent's name, paused state or spending pool. # Update agent policy Source: https://agents-docs.lightspark.com/api-reference/agent-management/update-agent-policy /openapi.yaml patch /agents/{agentId}/policy Partially update an agent's policy. Only provided fields will be updated; omitted fields retain their current values. Policy changes take effect immediately. The spending limits currency is immutable; attempting to change it returns a 400 response. # Add an external account Source: https://agents-docs.lightspark.com/api-reference/agent-operations/add-an-external-account /openapi.yaml post /agents/me/external-accounts Register a new external bank account or wallet for the authenticated agent's customer. Requires the MANAGE_EXTERNAL_ACCOUNTS permission in the agent's policy. The `customerId` field is optional and will be inferred from the agent's associated customer if omitted. Grid saves a Spark address under its network's current prefix (`spark1...`), so a legacy spelling (`sp1...`) of a saved address conflicts with it. Grid refuses to save a Spark address that was refused, or is held for review, on any of the customer's records of it, removed ones included (`DESTINATION_NOT_ALLOWED`). # Add the agent's card to a digital wallet Source: https://agents-docs.lightspark.com/api-reference/agent-operations/add-the-agents-card-to-a-digital-wallet /openapi.yaml post /agents/me/cards/{cardId}/tokenize Exchange the values an app on the cardholder's phone received from Apple Pay, Google Pay or Samsung Pay for the encrypted payload that adds one of the agent's `STANDARD` cards to that wallet (in-app push provisioning). The request and response are the same as `POST /cards/{id}/tokenize`. The payload is encrypted to the wallet provider's keys; hand it straight to the wallet SDK and never store or log it. Confirm with the cardholder before calling: a wallet token spends like the card. Every call is audit-logged with the agent as the actor, and calls are rate limited per agent and per card. Requires the MANAGE_CARDS permission. # Ask for a stored card's details Source: https://agents-docs.lightspark.com/api-reference/agent-operations/ask-for-a-stored-cards-details /openapi.yaml post /agents/me/stored-cards/{storedCardId}/reveal Ask to use a stored card for one purchase. The agent names the merchant, the amount it expects to be charged and what the purchase is; Grid cannot enforce the amount, since the merchant charges the customer's own card. The customer approves every use unless they chose to auto-approve this agent at this merchant up to the amount. A use that waits answers `202` with a `USE_STORED_CARD` action in `PENDING_APPROVAL`; once it is `APPROVED`, call again with only its `actionId` to collect the link. A use with `needsCvc` always waits, since the customer enters the security code when approving. An approved use answers `200` with a single-use `detailsUrl`, valid for five minutes. The number reaches the agent through Evervault's Relay, which decrypts it on the way; Grid never sends it. Open the URL once, right away, where the purchase is being made, and never store or log it. Each approved use issues one link: asking again with the same `actionId` is refused with `409`. Every use is audit-logged with the agent as the actor. Uses are rate limited per card, per agent and per customer; beyond the limit the request is rejected with `429 RATE_LIMITED`. Requires the USE_STORED_CARDS permission and the customer's permission for this agent to use the card. # Cancel a claimable payout Source: https://agents-docs.lightspark.com/api-reference/agent-operations/cancel-a-claimable-payout /openapi.yaml post /agents/me/claimable-payouts/{claimablePayoutId}/cancel Cancel a claimable payout the authenticated agent sent that is `PENDING_APPROVAL` or `AWAITING_CLAIM`, releasing its hold. Its claim link stops working. Cancelling a payout already `CANCELLED` returns it unchanged; one the recipient has claimed, or that has ended otherwise, is refused with `409`. # Cancel a held execution Source: https://agents-docs.lightspark.com/api-reference/agent-operations/cancel-a-held-execution /openapi.yaml post /agents/me/settlement-rules/{settlementRuleId}/executions/{executionId}/cancel Stop a `HELD` execution of a rule the authenticated agent created before it fires. It becomes `SKIPPED` with `CANCELLED`, nothing is paid, and the customer keeps the payment. Requires the MANAGE_SETTLEMENT_RULES permission. # Cancel a payout run Source: https://agents-docs.lightspark.com/api-reference/agent-operations/cancel-a-payout-run /openapi.yaml post /agents/me/payout-runs/{payoutRunId}/cancel Cancel everything of the run that has not been sent. A `PENDING` row becomes `SKIPPED` with `failureCode` `CANCELLED`. A contact row whose claimable payout is still `AWAITING_CLAIM` has its payout cancelled, which ends its claim link and releases its hold, and becomes `SKIPPED` with `failureCode` `CANCELLED`. `PROCESSING` and `COMPLETED` rows, and contact rows already claimed, are left to finish. A run still `PENDING_APPROVAL` becomes `CANCELLED`, and its `PAYOUT_RUN` action fails with `AGENT_CANCELLED`. A run that has finished is refused with `409`. # Create a payout run Source: https://agents-docs.lightspark.com/api-reference/agent-operations/create-a-payout-run /openapi.yaml post /agents/me/payout-runs Pay up to 50 people at once from the agent's customer's stablecoin Embedded Wallet, in one approved action. Each row pays a contact by phone number or email, through a claimable payout, or one of the customer's own `ACTIVE` external accounts, through a send. The run pays now: there is no schedule, and rows succeed or fail one by one. Validation is all or nothing: a row the agent may not send, or a body Grid cannot read, refuses the whole request and creates nothing. A `GRID_CUSTOMER` row is refused with `400`. A row paying an external account the agent may not pay is refused with `DESTINATION_NOT_ALLOWED`. The run is checked against the agent's policy as one payment of `total`: it needs the CREATE_QUOTES and EXECUTE_QUOTES permissions, and MANAGE_EXTERNAL_ACCOUNTS when a row pays a contact; `total` counts toward the spending limits as one transaction, and the spending pool is charged for bank payouts. A run the policy refuses is refused with `AGENT_POLICY_DENIED`. A run is created `PROCESSING`, with an `APPROVED` `PAYOUT_RUN` agent action, when the policy runs it without approval, the spending pool does not require approval and no row is above the platform's limit for bank payouts without approval. Otherwise it is created `PENDING_APPROVAL` with a `PENDING_APPROVAL` `PAYOUT_RUN` action, and the `AGENT_ACTION.PENDING_APPROVAL` webhook fires. Approving the action starts the run. Rejecting it, or letting it expire after 24 hours, cancels the run and skips every row. The customer's approval of the run covers each row's own approval. Each row is still checked when it is paid: the destination, the agent's delegated key, the wallet's free balance and sanctions screening. `total` is what leaves the wallet; a row's fees come out of what its recipient receives. Available only where the platform enables payout runs and agent payout runs, and claimable payouts for runs with a contact row. # Create a settlement rule Source: https://agents-docs.lightspark.com/api-reference/agent-operations/create-a-settlement-rule /openapi.yaml post /agents/me/settlement-rules Propose a standing split of the customer's incoming payments into its stablecoin Embedded Wallet, paid from that wallet, as `POST /settlement-rules` creates one for the platform. Preview it first with `POST /agents/me/settlement-rules/preview`. **The wallet:** every selector in `match` sets `creditedAccountId` to the customer's stablecoin Embedded Wallet that holds this agent's key, so the rule fires only on money landing in a wallet the agent can spend. `currency` is the wallet's. **Destinations:** a share pays a person by phone number or email (`CONTACT`), one of the customer's own external accounts (`EXTERNAL_ACCOUNT`), or an Embedded Wallet on the platform (`GRID_CUSTOMER`). Any other destination is refused with `DESTINATION_NOT_ALLOWED`. **Approval:** the rule is created `ACTIVE` when `limits.maxAmountPerExecution` is set and within the policy's per-transaction limit for the wallet, `limits.maxAmountPerDay` is set and within its daily limit, no `EXTERNAL_ACCOUNT` share can exceed the platform's limit for bank payouts without approval, and the policy sends from the wallet without approval at `maxAmountPerExecution`. Otherwise it is created `PENDING_APPROVAL` with a `CREATE_SETTLEMENT_RULE` agent action, named by `agentActionId`, and the `AGENT_ACTION.PENDING_APPROVAL` webhook fires. A pending rule holds its priority and fires on nothing. Approving the action makes the rule `ACTIVE`; rejecting it, or letting it expire after seven days, deletes it. **Priority:** an omitted `priority` goes after the customer's last rule that is not `DELETED`, the platform's rules included. Requires the MANAGE_SETTLEMENT_RULES permission. # Create a transfer quote Source: https://agents-docs.lightspark.com/api-reference/agent-operations/create-a-transfer-quote /openapi.yaml post /agents/me/quotes Generate a quote for a cross-currency transfer on behalf of the authenticated agent's customer. Accounts referenced in the request must belong to the agent's customer. Requires the CREATE_QUOTES permission in the agent's policy. The destination is another embedded wallet or one of the customer's Spark addresses saved with `POST /agents/me/external-accounts` (`ACCOUNT`), an address on another chain (`CHAIN_ADDRESS`), or a BOLT11 Lightning invoice (`LIGHTNING_INVOICE`). Grid answers `DESTINATION_NOT_ALLOWED` for a Spark address that was refused, or is held for review, on any of the customer's records of it, under either prefix and including removed ones. A `CHAIN_ADDRESS` quote sends `USDB`. Grid pays the chain itself at a locked rate, so the amount the recipient receives is fixed when the quote is created and equals the quote's `minimumReceivingAmount`. A `LIGHTNING_INVOICE` quote debits the source wallet and pays exactly the invoice's amount, which it locks on the receiving side in satoshis, through the platform's Lightning invoice quote (`POST /quotes`). If the agent's defaultExecutionMode is APPROVAL_REQUIRED, or the quote amount exceeds the agent's approvalThresholds or the approvalThreshold of the spending pool the agent belongs to, the resulting transaction will require explicit approval before funds move. # Create an agent deposit Source: https://agents-docs.lightspark.com/api-reference/agent-operations/create-an-agent-deposit /openapi.yaml post /agents/me/deposits Create a deposit for the authenticated agent's customer. The returned target is a BOLT11 invoice for Lightning or an address for a supported chain. Requires the RECEIVE_FUNDS permission in the agent's policy. # Delete a settlement rule Source: https://agents-docs.lightspark.com/api-reference/agent-operations/delete-a-settlement-rule /openapi.yaml delete /agents/me/settlement-rules/{settlementRuleId} The rule becomes `DELETED` and fires on no further payment. Its executions still held are skipped with `CANCELLED`. A rule still `PENDING_APPROVAL` is withdrawn, and a change waiting for approval is dropped: the waiting action fails with `DELETED_BY_AGENT`. Deleting a deleted rule returns it unchanged. Requires the MANAGE_SETTLEMENT_RULES permission. # Delete agent external account Source: https://agents-docs.lightspark.com/api-reference/agent-operations/delete-agent-external-account /openapi.yaml delete /agents/me/external-accounts/{externalAccountId} Delete an external account belonging to the authenticated agent's customer. Requires the MANAGE_EXTERNAL_ACCOUNTS permission in the agent's policy. # Execute a quote Source: https://agents-docs.lightspark.com/api-reference/agent-operations/execute-a-quote /openapi.yaml post /agents/me/quotes/{quoteId}/execute Execute a quote created by the authenticated agent. Requires the EXECUTE_QUOTES permission in the agent's policy. When the source is an `EMBEDDED_WALLET`, Grid signs with the agent's active delegated key for that wallet. If the agent's policy requires approval for this amount (based on execution mode or approval thresholds), the transaction will be created in a pending state and must be approved by the platform via `POST /agents/{agentId}/actions/{actionId}/approve`. The action expires when its quote expires, and approval after expiry fails the action, unless the action has `priceTolerance`: it then waits for approval until its own `expiresAt`. Approved while its quote is live, it executes that quote; approved after the quote expired, Grid quotes the send again (see `POST /agents/{agentId}/actions/{actionId}/approve`). A retry with the first quote's id still returns the action after approval replaced its quote. Once executed, the quote cannot be cancelled. # Generate an agent customer KYC link Source: https://agents-docs.lightspark.com/api-reference/agent-operations/generate-an-agent-customer-kyc-link /openapi.yaml post /agents/me/kyc-link Generate a single-use hosted KYC link for the authenticated agent's individual customer. Requires the MANAGE_IDENTITY permission in the agent's policy. # Get a claimable payout Source: https://agents-docs.lightspark.com/api-reference/agent-operations/get-a-claimable-payout /openapi.yaml get /agents/me/claimable-payouts/{claimablePayoutId} Retrieve a claimable payout the authenticated agent sent. # Get a payout run Source: https://agents-docs.lightspark.com/api-reference/agent-operations/get-a-payout-run /openapi.yaml get /agents/me/payout-runs/{payoutRunId} Retrieve a payout run the authenticated agent created. # Get a settlement rule Source: https://agents-docs.lightspark.com/api-reference/agent-operations/get-a-settlement-rule /openapi.yaml get /agents/me/settlement-rules/{settlementRuleId} Retrieve a settlement rule the authenticated agent created. A rule another agent or the platform created is not found. Requires the MANAGE_SETTLEMENT_RULES or VIEW_TRANSACTIONS permission. # Get agent card Source: https://agents-docs.lightspark.com/api-reference/agent-operations/get-agent-card /openapi.yaml get /agents/me/cards/{cardId} Retrieve one of the authenticated agent's cards. A card that does not belong to the agent is not found. Requires the MANAGE_CARDS permission. # Get agent customer Source: https://agents-docs.lightspark.com/api-reference/agent-operations/get-agent-customer /openapi.yaml get /agents/me/customer Retrieve the individual customer associated with the authenticated agent. Requires the MANAGE_IDENTITY permission in the agent's policy. # Get agent external account by ID Source: https://agents-docs.lightspark.com/api-reference/agent-operations/get-agent-external-account-by-id /openapi.yaml get /agents/me/external-accounts/{externalAccountId} Retrieve an external account belonging to the authenticated agent's customer. Returns 404 if the account exists but belongs to a different customer. Requires the MANAGE_EXTERNAL_ACCOUNTS permission in the agent's policy. # Get agent quote by ID Source: https://agents-docs.lightspark.com/api-reference/agent-operations/get-agent-quote-by-id /openapi.yaml get /agents/me/quotes/{quoteId} Retrieve a quote created by the authenticated agent. Returns 404 if the quote exists but was not created by this agent. # Get agent transaction by ID Source: https://agents-docs.lightspark.com/api-reference/agent-operations/get-agent-transaction-by-id /openapi.yaml get /agents/me/transactions/{transactionId} Retrieve a specific transaction belonging to the authenticated agent's customer. Returns 404 if the transaction exists but belongs to a different customer. # Get an agent action Source: https://agents-docs.lightspark.com/api-reference/agent-operations/get-an-agent-action /openapi.yaml get /agents/me/actions/{actionId} Retrieve a specific action submitted by the authenticated agent. Poll this endpoint after submitting an action that requires approval to check whether it has been approved, rejected, or has failed. # Get an agent deposit Source: https://agents-docs.lightspark.com/api-reference/agent-operations/get-an-agent-deposit /openapi.yaml get /agents/me/deposits/{depositId} Retrieve a deposit created by the authenticated agent. Requires the RECEIVE_FUNDS permission in the agent's policy. # Get current agent Source: https://agents-docs.lightspark.com/api-reference/agent-operations/get-current-agent /openapi.yaml get /agents/me Retrieve the authenticated agent's own profile, policy, and current usage. This endpoint is called by the agent software itself using its own credentials (obtained via device code redemption) rather than platform credentials. # Increase a purchase card amount Source: https://agents-docs.lightspark.com/api-reference/agent-operations/increase-a-purchase-card-amount /openapi.yaml post /agents/me/cards/{cardId}/increase Increase an open, unused single-use purchase card belonging to this agent. Recurring cards cannot be increased. Requires ISSUE_CARDS and MANAGE_CARDS and platform enablement. The new amount must be greater than the current amount and within the platform's purchase-card maximum. The new issuer limit includes the platform's tax and shipping allowance. Returns an ISSUE_PURCHASE_CARD action whose `purchaseCard.increaseCardId` is the card being increased. It waits for the customer's approval through the ordinary action approval endpoint when the agent's current policy requires approval for the new limit (the funding account's execution mode, else the default execution mode, or the approval threshold), or when the original card required approval. Otherwise it is applied at once. Approval rechecks the card, limits and platform gate. The full new issuer limit is checked against the agent's current spending limits. Increases do not reserve or count spending; authorization counts the actual spend. Replaying the same key returns the same action; a key used with different parameters is a conflict. # Issue an agent card Source: https://agents-docs.lightspark.com/api-reference/agent-operations/issue-an-agent-card /openapi.yaml post /agents/me/cards Issue a virtual card for the authenticated agent. The card belongs to the agent's customer, who must have KYC status `APPROVED` and have accepted the cardholder agreement where the platform requires one. Its spend counts against the agent's policy limits as the agent's sends do, and Grid's default agent merchant-category blocks apply. Requires the ISSUE_CARDS permission; reading the card afterwards needs MANAGE_CARDS. **Business customers**: where the platform enables agents for business customers, an agent of a `BUSINESS` customer may be issued standard and purchase cards once the business's KYB status is `APPROVED`. The request must name the person the card is for in `cardholder`; the card is issued on the business's card account with that person as its authorized user, and carries the agent's `actingFor`. A purchase card awaiting approval is issued to that person when it is approved. **Standard card** (`kind` omitted or `STANDARD`): as `POST /cards` with `agentId` does for the platform. While any of the agent's standard cards is not `CLOSED`, including one the platform issued for it, the request is rejected with `AGENT_CARD_EXISTS`. New cards start in `status: "PROCESSING"` and follow the same status transitions and `card.status_change` webhooks as cards issued with `POST /cards`. **Purchase card** (`kind: PURCHASE`): a single-use card for one online purchase of `amount`, paid from the customer's Embedded Wallet with the agent's delegated key. Available only where the platform enables agent purchase cards. Its limit (`purchase.spendLimit`) is `amount` plus an allowance for tax and shipping, and that limit is evaluated against the agent's policy as a send of the same amount would be: - Under the policy's approval threshold and the approval threshold of the agent's spending pool, if any, and with `defaultExecutionMode` `AUTO`, the card is issued at once and returned with `201`. - Otherwise the request becomes an `ISSUE_PURCHASE_CARD` agent action in `PENDING_APPROVAL`, returned with `202`, and the `AGENT_ACTION.PENDING_APPROVAL` webhook fires. The card is issued when the action is approved with `POST /agents/{agentId}/actions/{actionId}/approve`; the action then carries `cardId`. Poll `GET /agents/me/actions/{actionId}` for the outcome. - A request the policy denies outright (over a spending limit, or a funding account the policy does not allow) is rejected with `403`. The card is closed by its first approved authorization, and Grid closes it if it is still unused at `purchase.expiresAt`. Purchase cards are not counted by the one-standard-card rule, but an agent may hold only a limited number of open purchase cards, counting those awaiting approval; beyond that the request is rejected with `PURCHASE_CARD_LIMIT_REACHED`. **Recurring purchase card** (`kind: PURCHASE`, `recurring: true`): for a subscription, a card saved with a merchant, a hotel or car rental, or an order that ships in parts. Available only where the platform enables recurring purchase cards. The card locks to the first merchant that charges it and declines every other merchant; that merchant may charge it again, up to `amount` in any trailing month (from a charge back to the same date a month earlier), with no allowance added. It always becomes an `ISSUE_PURCHASE_CARD` action awaiting the customer's approval and is returned with `202`, whatever the policy's approval threshold or execution mode, though a request the policy denies is still rejected with `403`. Its charges count against the agent's policy limits as they are authorized. It has no `purchase.expiresAt` and stays open until the customer or platform closes it (`PATCH /cards/{id}` with `status: CLOSED`, a `substatus` and a `reason`). Pausing the agent does not close it, but its charges are declined while the agent is paused; deleting the agent freezes it, and `POST /agents/{agentId}/card-handover` with `kind=PURCHASE` can move it to the customer's next agent. Recurring cards have their own, smaller limit on open cards, counting those awaiting approval, and do not use up the room for single-use cards. Card details are never returned here; request them with `POST /agents/me/cards/{cardId}/reveal`. Card issuance cannot be reversed, so an `Idempotency-Key` header is required. Keys are scoped to the agent: a retry by the same agent with the same key returns what the first request created, whatever has changed since, and another agent's key never matches. For a purchase card, a retry returns the issued card with `201`, or the action with `202` while it is pending, rejected or failed. # List agent cards Source: https://agents-docs.lightspark.com/api-reference/agent-operations/list-agent-cards /openapi.yaml get /agents/me/cards List the authenticated agent's cards: standard cards issued for it and purchase cards it issued. Filter with `kind=PURCHASE` for purchase cards only. Other cards of the customer are not listed. Requires the MANAGE_CARDS permission. # List agent external accounts Source: https://agents-docs.lightspark.com/api-reference/agent-operations/list-agent-external-accounts /openapi.yaml get /agents/me/external-accounts Retrieve a paginated list of external accounts belonging to the authenticated agent's customer. Requires the MANAGE_EXTERNAL_ACCOUNTS permission in the agent's policy. # List agent transactions Source: https://agents-docs.lightspark.com/api-reference/agent-operations/list-agent-transactions /openapi.yaml get /agents/me/transactions Retrieve a paginated list of transactions for the authenticated agent's customer. Results are automatically scoped to the agent's associated customer — no customer filter is needed or accepted. # List agent's internal accounts Source: https://agents-docs.lightspark.com/api-reference/agent-operations/list-agents-internal-accounts /openapi.yaml get /agents/me/internal-accounts Retrieve the internal accounts belonging to the customer this agent operates on behalf of. Use this to discover available source accounts for transfers and quotes, and to verify which accounts are accessible under the agent's `accountRestrictions` policy. # List agent's own actions Source: https://agents-docs.lightspark.com/api-reference/agent-operations/list-agents-own-actions /openapi.yaml get /agents/me/actions Retrieve a paginated list of actions submitted by the authenticated agent. Use this to poll for approval decisions after submitting an action that requires approval. # List claimable payouts Source: https://agents-docs.lightspark.com/api-reference/agent-operations/list-claimable-payouts /openapi.yaml get /agents/me/claimable-payouts List the claimable payouts the authenticated agent sent, newest first. # List payout runs Source: https://agents-docs.lightspark.com/api-reference/agent-operations/list-payout-runs /openapi.yaml get /agents/me/payout-runs List the payout runs the authenticated agent created, newest first. # List settlement rules Source: https://agents-docs.lightspark.com/api-reference/agent-operations/list-settlement-rules /openapi.yaml get /agents/me/settlement-rules The settlement rules the authenticated agent created, oldest first, with their shares. Requires the MANAGE_SETTLEMENT_RULES or VIEW_TRANSACTIONS permission. # List the executions of a settlement rule Source: https://agents-docs.lightspark.com/api-reference/agent-operations/list-the-executions-of-a-settlement-rule /openapi.yaml get /agents/me/settlement-rules/{settlementRuleId}/executions The payments a rule the authenticated agent created fired on, newest first. Requires the MANAGE_SETTLEMENT_RULES or VIEW_TRANSACTIONS permission. # List the rows of a payout run Source: https://agents-docs.lightspark.com/api-reference/agent-operations/list-the-rows-of-a-payout-run /openapi.yaml get /agents/me/payout-runs/{payoutRunId}/rows The rows of a run the authenticated agent created, in the order they were submitted. # List the stored cards the agent may use Source: https://agents-docs.lightspark.com/api-reference/agent-operations/list-the-stored-cards-the-agent-may-use /openapi.yaml get /agents/me/stored-cards The customer's `ACTIVE` stored cards the customer allowed this agent to use, with display fields only. Requires the USE_STORED_CARDS permission. # Open a stored card link Source: https://agents-docs.lightspark.com/api-reference/agent-operations/open-a-stored-card-link /openapi.yaml get /stored-card-reveals/{token} What a stored card's `detailsUrl` answers, once. Open the `detailsUrl` itself, never this path on Grid's host: `detailsUrl` goes through Evervault's Relay, which decrypts `number` and `cvc` on the way, and Grid answers them only as Evervault ciphertext. The token is the only credential. A link that was already opened, has expired, or whose card or agent lost access answers `404`. The same path answers a link from `createStoredCardOwnerReveal`, which never carries `cvc`, and the response allows the origin `https://ui-components.evervault.com` (CORS) so Evervault's Reveal component can open it. # Preview a payout run Source: https://agents-docs.lightspark.com/api-reference/agent-operations/preview-a-payout-run /openapi.yaml post /agents/me/payout-runs/preview What `POST /agents/me/payout-runs` would do with this body, without doing any of it: nothing is created, held, reserved or quoted. The body is checked as a create checks it, so a body the create would refuse is refused here with the same error. The answer gives `total`, which is what leaves the wallet, the wallet's free balance after it, and whether the run would wait for the customer's approval and why. When the agent's policy would refuse the run, `denialReason` says which rule. Each row's fee is an estimate where Grid knows it: a contact row's fee depends on the account the recipient claims to, so it is set at the claim. # Preview a settlement rule Source: https://agents-docs.lightspark.com/api-reference/agent-operations/preview-a-settlement-rule /openapi.yaml post /agents/me/settlement-rules/preview What a rule the agent drafts would pay on a payment of `amount`, and which of the customer's recent payments it would have fired on, as `POST /settlement-rules/preview` answers for a draft. Nothing is created or paid. The body is the draft's fields and `amount`, with no `customerId`: the rule is for the agent's customer. The draft is checked as `POST /agents/me/settlement-rules` checks a rule, so a draft the agent could not create is refused here first: every selector's `creditedAccountId` is the customer's stablecoin Embedded Wallet that holds this agent's key, `currency` is the wallet's, and every share pays a destination an agent may pay, or the preview is refused with `DESTINATION_NOT_ALLOWED`. Requires the MANAGE_SETTLEMENT_RULES permission. # Reveal a purchase card Source: https://agents-docs.lightspark.com/api-reference/agent-operations/reveal-a-purchase-card /openapi.yaml post /agents/me/cards/{cardId}/reveal Mint a short-lived signed URL of the card processor's page that displays one of the agent's open `PURCHASE` cards: its full number, expiry and CVV. The card details never pass through Grid; open the URL where the purchase is being made, right away, and never store or log it. Standard cards are revealed only by the platform with `POST /cards/{id}/reveal`. Every reveal is audit-logged with the agent as the actor. Reveals are rate limited per agent and per card; beyond the limit the request is rejected with `429 RATE_LIMITED`. Requires the MANAGE_CARDS permission. # Send a claimable payout Source: https://agents-docs.lightspark.com/api-reference/agent-operations/send-a-claimable-payout /openapi.yaml post /agents/me/claimable-payouts Send money from the agent's customer's Embedded Wallet to a person by phone number or email. The recipient needs no Grid account: she claims the payout from a link the platform sends her, choosing how to receive it from what Grid pays out to in her country, and Grid pays her from the customer's wallet. Grid holds `amount` against the wallet's balance and the agent's spending limits from creation. Funds do not leave the wallet until the recipient claims; while the payout is held, quotes and card payments from the wallet cannot spend the held amount. The hold is released when the payout expires, is cancelled or rejected, or fails. The request is evaluated against the agent's policy as a send of the same amount to a bank account: it needs the EXECUTE_QUOTES permission, counts toward the spending limits, and needs the customer's approval above the policy's approval threshold or its spending pool's `approvalThreshold`, in `APPROVAL_REQUIRED` mode, or above the platform's limit for bank payouts without approval. A payout that needs approval is created `PENDING_APPROVAL` with a `CLAIMABLE_PAYOUT` agent action, and the `AGENT_ACTION.PENDING_APPROVAL` webhook fires; approving the action makes it `AWAITING_CLAIM`. Otherwise it is created `AWAITING_CLAIM`. Then issue the recipient's link with `POST /claimable-payouts/{claimablePayoutId}/claim-token`. Available only where the platform enables claimable payouts and agent bank payouts, for a customer whose KYC status is `APPROVED`. # Submit agent customer for verification Source: https://agents-docs.lightspark.com/api-reference/agent-operations/submit-agent-customer-for-verification /openapi.yaml post /agents/me/verifications Submit the authenticated agent's individual customer for KYC verification. The response reports the current status and any identity fields that still need to be supplied. Requires the MANAGE_IDENTITY permission in the agent's policy. # Tighten or freeze an agent card Source: https://agents-docs.lightspark.com/api-reference/agent-operations/tighten-or-freeze-an-agent-card /openapi.yaml patch /agents/me/cards/{cardId} Tighten a card's merchant categories, or freeze it. An agent can never loosen a card or unfreeze it; the platform does that with `PATCH /cards/{id}`. Requires the MANAGE_CARDS permission. # Update a settlement rule Source: https://agents-docs.lightspark.com/api-reference/agent-operations/update-a-settlement-rule /openapi.yaml patch /agents/me/settlement-rules/{settlementRuleId} Change a rule the authenticated agent created. Send only the fields to change. **At once:** `name`, `status` (`ACTIVE` or `PAUSED`), `holdSeconds` and `achHoldSeconds` apply when the request is made. **Changes that can move more money:** `match`, `splits` and `limits` are checked as on create, on the rule as they would make it. When that rule is within the agent's policy, as `POST /agents/me/settlement-rules` decides, the change applies at once. Otherwise it waits for the customer's approval as an `UPDATE_SETTLEMENT_RULE` agent action, named by `pendingChangeActionId`, and the `AGENT_ACTION.PENDING_APPROVAL` webhook fires. Until the action is decided the rule keeps its current `match`, `splits` and `limits`. The fields that apply at once still do, so a request that also sets `status` to `PAUSED` pauses the rule straight away. Approving the action checks the change again and applies it; rejecting it, or letting it expire after seven days, drops it. A rule has at most one change waiting. `splits` replaces every share and `limits` replaces both limits, as on `PATCH /settlement-rules/{settlementRuleId}`. An agent cannot change `priority`. Requires the MANAGE_SETTLEMENT_RULES permission. # Update agent customer Source: https://agents-docs.lightspark.com/api-reference/agent-operations/update-agent-customer /openapi.yaml patch /agents/me/customer Update identity information for the individual customer associated with the authenticated agent. Requires the MANAGE_IDENTITY permission in the agent's policy. The customer is inferred from the agent and cannot be selected by the request. Agents may update only identity fields: `fullName`, `birthDate`, `nationality`, `address`, `idType`, `identifier`, `countryOfIssuance` and the enhanced-due-diligence fields, plus a `phoneNumber` when the customer has none on file. Any other field, including `email` and a change to a phone number on file (sign-in credentials the customer changes through `PATCH /customers/{customerId}`), returns 400. # Create a new API token Source: https://agents-docs.lightspark.com/api-reference/api-tokens/create-a-new-api-token /openapi.yaml post /tokens Create a new API token to access the Grid APIs. # Delete API token by ID Source: https://agents-docs.lightspark.com/api-reference/api-tokens/delete-api-token-by-id /openapi.yaml delete /tokens/{tokenId} Delete an API token by their system-generated ID # Get API token by ID Source: https://agents-docs.lightspark.com/api-reference/api-tokens/get-api-token-by-id /openapi.yaml get /tokens/{tokenId} Retrieve an API token by their system-generated ID # List tokens Source: https://agents-docs.lightspark.com/api-reference/api-tokens/list-tokens /openapi.yaml get /tokens Retrieve a list of API tokens with optional filtering parameters. Returns all tokens that match the specified filters. If no filters are provided, returns all tokens (paginated). # Authentication Source: https://agents-docs.lightspark.com/api-reference/authentication Grid API uses HTTP Basic Authentication with your client ID as the username and your client secret as the password.\ \[!NOTE] Your API tokens are scoped to either your production or sandbox environment. ```bash theme={null} curl -u "{client_id}:{client_secret}" https://api.lightspark.com/grid/2025-10-13... ``` # Add a card to a digital wallet Source: https://agents-docs.lightspark.com/api-reference/cards/add-a-card-to-a-digital-wallet /openapi.yaml post /cards/{id}/tokenize Exchange the values your app received from Apple Pay, Google Pay, or Samsung Pay for the encrypted provisioning payload that completes adding the card to that wallet (in-app push provisioning). Call it from your backend inside the wallet SDK's credentials callback and hand the response straight back to the SDK. The payload is encrypted to the wallet provider's keys, so neither your app nor your servers ever see card data. Do not store or log it; it is valid for one provisioning attempt. Cardholders can always add a card to a wallet by typing its details in manually — that path needs nothing from you. This endpoint is for the in-app "Add to Wallet" button, which additionally requires approval from each wallet provider. See [Digital wallet tokenization](/cards/card-management/digital-wallet-tokenization). Only `ACTIVE` cards can be added. Every call is audit-logged with the requesting actor and the target wallet. # Add a card to a digital wallet from a website Source: https://agents-docs.lightspark.com/api-reference/cards/add-a-card-to-a-digital-wallet-from-a-website /openapi.yaml post /cards/{id}/web-tokenize Mint the payload Apple's or Google's web "Add to Wallet" button needs to add the card to the cardholder's wallet from your website (web push provisioning). Call it from your backend when the button asks for the card: from Apple's `jwsResolver`, or from Google's `onSessionCreated` with the three values it supplies. Hand the response straight to the wallet's script. The payload is encrypted to the wallet provider's keys, so neither your site nor your servers ever see card data. Do not store or log it; it is valid for one attempt. Authenticate the cardholder before calling. Web push provisioning is enabled per website by the card issuer and needs each wallet provider's approval of your site. Until then the call returns `409 CONFLICT`. See [Digital wallet tokenization](/cards/card-management/digital-wallet-tokenization). Only `ACTIVE` cards can be added. Every call is audit-logged with the requesting actor and the target wallet. # Create a card velocity limit Source: https://agents-docs.lightspark.com/api-reference/cards/create-a-card-velocity-limit /openapi.yaml post /card-velocity-limits Create a rule for one card or for every eligible card on your platform. A rule with the same cardId, metric, window, and windowSeconds returns 409 CONFLICT, even if its name or caps differ. The error identifies the existing rule. Delete that rule before creating its replacement; these requests are separate operations, so other applicable limits govern authorizations between them. Different windows, rolling durations, and card/platform scopes may coexist. At least one cap is required. A target can hold at most 50 rules. Amounts use the smallest unit of each card's currency; USD- and USDB-funded USD cards both use USD cents. Manage per-authorization amount, UTC calendar amount/count, and rolling amount/count caps through this collection. Limits are not configured on card issuance/update requests or platform cardConfigs. APPROVED counts approved authorizations, including zero-amount approvals; ATTEMPTS counts real-time authorization requests, including declines. Reversals and expiries restore held-amount capacity but do not remove an approved authorization from the count. Clearing against a hold does not restore amount capacity. Resolve each cap at the most specific configured scope: card, then platform, then program. Amount and count inherit separately for each metric/window; rolling durations are distinct windows. Deleting the last cap at a scope restores the next configured scope. Applies only where Grid makes authorization decisions. A cardId naming a provider-decided card is rejected. Platform-wide rules apply separately to each eligible card. AUTHORIZATION caps also protect asynchronous authorizations; ATTEMPTS counts real-time requests only. Settlement of previously authorized transactions continues even if a rule is tightened. # Delete a card velocity limit Source: https://agents-docs.lightspark.com/api-reference/cards/delete-a-card-velocity-limit /openapi.yaml delete /card-velocity-limits/{id} Remove a rule managed by your platform. Other applicable controls remain in effect. Rules owned by other platforms or Lightspark return 404. Removing a rule does not change answers already recorded for earlier authorization requests. Delete and recreate are separate operations; authorizations between them use the other applicable limits, and a failed replacement does not restore the deleted rule. # Get a card Source: https://agents-docs.lightspark.com/api-reference/cards/get-a-card /openapi.yaml get /cards/{id} Retrieve a card by its system-generated id. To display the card's full PAN, CVV, and expiry to the cardholder, request a reveal with `POST /cards/{id}/reveal` — the card resource itself never carries the reveal URL. # Get a card dispute Source: https://agents-docs.lightspark.com/api-reference/cards/get-a-card-dispute /openapi.yaml get /card-disputes/{disputeId} Returns one dispute on a card transaction the platform owns. A dispute not yet linked to a transaction returns `404`. # Get a card velocity limit Source: https://agents-docs.lightspark.com/api-reference/cards/get-a-card-velocity-limit /openapi.yaml get /card-velocity-limits/{id} Retrieve a rule managed by your platform. Rules owned by other platforms or Lightspark return 404. # Get a PIN-entry URL Source: https://agents-docs.lightspark.com/api-reference/cards/get-a-pin-entry-url /openapi.yaml get /cards/{id}/pin-entry-url Get a temporary URL for a secure PIN-entry iframe. The cardholder chooses a four-digit PIN inside the iframe, without the plaintext PIN crossing your servers or Grid's. Grid supports online PINs only. Configure `cardConfigs.pinTargetOrigin` through `PATCH /config` before requesting a URL. It is the canonical HTTPS origin of the page that hosts the iframe. The iframe is restricted to that origin; this endpoint takes no request body or origin parameter. Each call creates a fresh, temporary PIN-entry session. Request a URL immediately before displaying the form. Fetching the URL does not set or change the PIN. Set your iframe's `src` to the returned `iframeUrl` without modifying it. Follow the [Card PIN guide](https://docs.lightspark.com/cards/card-management/pins) to submit the form and confirm the result. Never store, cache, or log the URL or token. The session expires at `expiresAt` and permits one successful PIN submission. Responses include `Cache-Control: no-store`. Use the iframe's submission result to confirm completion, and `GET /cards/{id}` to read `pinStatus`. `OK` alone cannot confirm a change to an already configured PIN. If you operate your own PIN-entry UI, use `POST /cards/{id}/set-pin` with a client-encrypted payload instead. Requires permission to manage cards. Every successful URL request is audit-logged with the requesting actor. # Issue a card Source: https://agents-docs.lightspark.com/api-reference/cards/issue-a-card /openapi.yaml post /cards Issue a new card for a cardholder. Every card is bound to one internal account, `fundingSource`, at create time. The cardholder must have KYC status `APPROVED` before a card can be issued; otherwise the request is rejected with `CARDHOLDER_KYC_NOT_APPROVED`. Card issuance is fee-bearing and cannot be reversed, so an `Idempotency-Key` header is required. Retries must carry the same key. Configure card velocity limits through `POST /card-velocity-limits`. Omit cardId to set platform defaults before issuance, or supply the returned card id to set card-specific limits after issuance. Limits use the smallest unit of the card's currency, including USD cents for USDB-funded USD cards. If the funding source is an Embedded Wallet internal account, the cardholder must authorize Grid to sign Spark token transactions for that card funding source by completing the delegated-key creation flow with `POST /auth/delegated-keys`. Until an active delegated key exists for that funding source, Authorization Decisioning cannot use it to fund card transactions. A platform may be limited to a maximum number of live cards. Once that limit is reached, further issuance is rejected with `CARD_LIMIT_REACHED` until a card is closed or Lightspark raises the limit. Cards in `CLOSED` status do not count toward the limit. New cards start in `status: "PROCESSING"` while the card issuer provisions the card. The `card.status_change` webhook fires on each status transition, including the transition to `ACTIVE` (or to `CLOSED` with `statusReason: "ISSUER_REJECTED"` if provisioning fails). A provisioned card funded by a USDB Embedded Wallet remains `PENDING_AUTH` with `statusReason: "DELEGATION_REQUIRED"` until all signed retries of `POST /auth/delegated-keys` are complete. Replacing USD funding with USDB without an active key, or revoking the required key, also moves an active card to `PENDING_AUTH`. Completing delegation or switching back to USD restores `ACTIVE`; frozen and closed cards keep their status. # List card velocity limits Source: https://agents-docs.lightspark.com/api-reference/cards/list-card-velocity-limits /openapi.yaml get /card-velocity-limits List the rules your platform manages. Without cardId, lists platform-wide rules; with cardId, lists only rules configured directly on that card. Lightspark-managed controls are enforced independently and are not exposed by this endpoint. Rules apply to cards whose authorization decisions are made by Grid; provider-decided cards are unaffected. # List cards Source: https://agents-docs.lightspark.com/api-reference/cards/list-cards /openapi.yaml get /cards Retrieve a paginated list of cards. Cards can be filtered by cardholder, bound funding-source internal account, status, and platform-specific card identifier. If no filters are provided, returns all cards visible to the caller. # List the disputes on a card transaction Source: https://agents-docs.lightspark.com/api-reference/cards/list-the-disputes-on-a-card-transaction /openapi.yaml get /transactions/{transactionId}/disputes Returns every dispute on a card transaction, newest first. Opening a dispute does not change the transaction's `status`. # Reveal card details Source: https://agents-docs.lightspark.com/api-reference/cards/reveal-card-details /openapi.yaml post /cards/{id}/reveal When the card's `cardCapabilities.supportsPanReveal` is true, mint a signed, short-lived URL for the card processor's iframe that displays the card's full PAN, CVV, and expiry to the cardholder. The `Card` resource never carries a reveal URL. When `cardCapabilities.supportsPanReveal` is false, this endpoint cannot reveal the card. Use the card issuer's challenge-based hosted reveal flow instead. Request the reveal right before rendering the iframe and render the returned `panEmbedUrl` immediately; it expires at `expiresAt` (within minutes). Never store, cache, or log the URL — it is a bearer secret for the full card details. The card data renders inside the processor's iframe and never crosses Grid's or your servers. Set `cardConfigs.panRevealCssUrl` on your platform config to style the iframe with your own stylesheet, or send `cssUrl` in the body to override it for one reveal. Every reveal is audit-logged with the requesting actor. # Set a card PIN Source: https://agents-docs.lightspark.com/api-reference/cards/set-a-card-pin /openapi.yaml post /cards/{id}/set-pin Set or replace the card's online PIN using a block encrypted in your client with the published PIN encryption public key. Grid cannot decrypt the block and never stores or logs PIN material. Use this when you operate your own PIN-entry UI. For a hosted PIN-entry iframe, use `GET /cards/{id}/pin-entry-url` — the two are alternatives, and a card may use either at any time. Setting a PIN clears a `BLOCKED` PIN, so a cardholder locked out by incorrect attempts can recover by choosing a new PIN without a separate unblock. Read `GET /cards/{id}` for the resulting status. Grid supports online PINs only; offline PIN cards are not supported. Every call is audit-logged with the requesting actor. # Unblock a card PIN Source: https://agents-docs.lightspark.com/api-reference/cards/unblock-a-card-pin /openapi.yaml post /cards/{id}/pin/unblock Clear a `BLOCKED` PIN, restoring the cardholder's existing PIN without changing it. A PIN blocks after three consecutive incorrect entries; until it is cleared, PIN-verified transactions decline. Unblocking is idempotent and safe to call on a card whose `pinStatus` is already `OK`. It has no effect on a card with no PIN set, which returns `409` — set a PIN with `POST /cards/{id}/set-pin` instead. Each card permits one unblock attempt per rolling seven days. Further attempts return `409 PIN_CHANGE_REQUIRED` with `details.pinUnblockAvailableAt`. Change the PIN through either PIN-entry flow to recover sooner; changing the PIN does not reset the unblock allowance. Only request an unblock after authenticating the cardholder and receiving their explicit request. Do not automatically unblock after a declined transaction. Grid supports online PINs only. Offline PIN cards are not supported. Read `GET /cards/{id}` to check `pinStatus`. Every call is audit-logged with the requesting actor. # Update a card Source: https://agents-docs.lightspark.com/api-reference/cards/update-a-card /openapi.yaml patch /cards/{id} Update a card's status, funding source, merchant categories, risk ceiling, or threeDSecurePassword. At least one field must be supplied. Manage velocity limits through `/card-velocity-limits`. - `status` transitions include `ACTIVE ⇄ FROZEN`, `PENDING_AUTH → ACTIVE | FROZEN`, and `ACTIVE | PENDING_AUTH | FROZEN → CLOSED`. `CLOSED` is terminal and irreversible. Activating a card whose USDB funding source lacks a usable delegated key returns `409 INVALID_STATE_TRANSITION`. `PENDING_AUTH` is managed by Grid and cannot be requested. Completing delegation does not unfreeze or reopen a card. Requesting `status: ACTIVE` returns `409 INVALID_STATE_TRANSITION` while the customer is not `ACTIVE`, for a card Grid froze for review, and for a card frozen with substatus `COMPROMISED`. Contact Grid support to have such a card reviewed, or close a compromised card and issue a new one. - `fundingSource`, when supplied, replaces the card's bound internal account. It must belong to the customer and use a currency supported by the card program, including USDB for USD cards. An active card becomes `PENDING_AUTH` when switched to USDB without an active delegated key. Switching back to USD restores readiness. `fundingSource` cannot be supplied alongside `status: CLOSED`. On card programs where the card issuer makes authorization decisions, `fundingSource` cannot be combined with any `status` change, so send the changes as separate requests. On card programs where Grid makes the authorization decision, the combination remains valid for `status` changes other than `CLOSED`. This endpoint is authenticated by the platform credential alone and returns `200` directly. It deliberately does not use Grid's 202 → signed-retry pattern: that pattern signs with the session key of a credential on the owning internal account, so it models actions taken *by* the end user on their own credentials or funds. Freezing or closing a card is routinely an action taken *about* a user and without them present - fraud response, offboarding, an ops-driven freeze - and requiring the cardholder's signature would make exactly those cases impossible. Operations that expose sensitive card data (`POST /cards/{id}/reveal`, 3DS password retrieval) are SCA-railed instead, because there the cardholder is the party being served. Effects: - `status: FROZEN`: Authorization Decisioning declines new auths with `cardDeclinedReason: CARD_NOT_ACTIVE`. Existing pulls and in-flight reconciliation continue — freezing does not pause the lifecycle of authorizations that already passed. - `status: ACTIVE`: normal authorization behavior resumes. - `status: CLOSED`: terminal close. The card transitions to `status: "CLOSED"` with `statusReason: "CLOSED_BY_PLATFORM"` and stays in the system for audit and reconciliation. All pending auths reconcile to a terminal state via the existing reconcile primitive. Inbound clearings received after close follow the standard force-post / late-presentment path — Lightspark absorbs the loss if a post-hoc pull on the now-unbound source fails. The funding source is detached. Refunds already in flight still complete because Lightspark holds the card-reserve keys. - `fundingSource` change: returns the updated card with the new binding and fires no webhook. The `card.status_change` webhook fires on every successful `status` transition. # Confirm a claim Source: https://agents-docs.lightspark.com/api-reference/claimable-payouts/confirm-a-claim /openapi.yaml post /claimable-payouts/claim/confirm Pay a claimable payout on the locked quote from `POST /claimable-payouts/claim/quote`. The payout becomes `CLAIMED`, and Grid signs the payout from the sender's wallet with the agent's delegated key; it becomes `PAID` when the payout completes, or `FAILED`. A quote that has expired is refused with `QUOTE_EXPIRED`: request another. Confirming a payout already `CLAIMED` or `PAID` on the same quote returns it unchanged. # Issue a claim token Source: https://agents-docs.lightspark.com/api-reference/claimable-payouts/issue-a-claim-token /openapi.yaml post /claimable-payouts/{claimablePayoutId}/claim-token Issue the claim token for the recipient's link to a payout that is `AWAITING_CLAIM`. Grid stores only its hash, so it is returned once; issuing another, for a reminder, replaces it and the earlier link stops working. Put it in a link to your own claim page and send it to the payout's `recipient.phoneNumber` or `recipient.email`. Never show it to the agent. # List payout options for a claim Source: https://agents-docs.lightspark.com/api-reference/claimable-payouts/list-payout-options-for-a-claim /openapi.yaml post /claimable-payouts/claim/options The currencies and rails Grid can pay a claimable payout to in a country, priced for the payout's amount. It is the platform's corridor set from `GET /exchange-rates`, limited to the country's currencies. An empty `options` means Grid cannot pay out in that country. # Look up a claim Source: https://agents-docs.lightspark.com/api-reference/claimable-payouts/look-up-a-claim /openapi.yaml post /claimable-payouts/claim/lookup The claimable payout a claim token is for, as the platform's claim page needs it. The page should show the recipient only `senderName` and `amount` until she has entered a one-time code sent to `recipient.phoneNumber` or `recipient.email`, so that a forwarded link alone cannot claim the payout. A token that matches no payout, or one that was replaced, is answered with `404`. # Quote a claim Source: https://agents-docs.lightspark.com/api-reference/claimable-payouts/quote-a-claim /openapi.yaml post /claimable-payouts/claim/quote Lock a quote paying a claimable payout to the recipient's account. Call it only after the recipient has proved she holds the payout's phone number or email. Grid validates `accountInfo` as `POST /customers/external-accounts` does, screens the recipient, and saves her account under the sender's customer, where an agent can pay it again. Grid then quotes the payout's full `amount` from the sender's wallet to that account. The payout stays `AWAITING_CLAIM` until the quote is confirmed; calling again replaces the quote. # CLI Source: https://agents-docs.lightspark.com/api-reference/cli Command-line interface for the Grid API The Grid CLI lets you interact with the Grid API directly from your terminal — manage customers, send payments, create quotes, and test in sandbox without writing any code. > [card] [Grid CLI](https://github.com/lightsparkdev/grid-api/tree/main/cli) > > Open-source CLI built with Node.js and TypeScript. ### Installation **Prerequisites:** Node.js v20 or v22 is required. ```bash theme={null} git clone https://github.com/lightsparkdev/grid-api.git cd grid-api/cli npm install npm run build npm link # makes `grid` available globally ``` ### Configuration Configure your credentials interactively: ```bash theme={null} grid configure ``` This prompts for your API Token ID and Client Secret, validates them, and saves to `~/.grid-credentials`. Alternatively, set environment variables: ```bash theme={null} export GRID_API_TOKEN_ID="your-token-id" export GRID_API_CLIENT_SECRET="your-client-secret" ``` ### Commands | Command | Description | | - | - | | `grid configure` | Set up API credentials | | `grid config get` | View platform configuration | | `grid customers list` | List customers | | `grid customers create` | Create a customer | | `grid accounts internal list` | List internal accounts | | `grid accounts external create` | Create an external bank account or wallet | | `grid quotes create` | Create a payment quote | | `grid quotes execute` | Execute a pending quote | | `grid transfers in` | Transfer funds in (external to internal) | | `grid transfers out` | Transfer funds out (internal to external) | | `grid transactions list` | List transactions | | `grid transactions get` | Get transaction details | | `grid receiver lookup-uma` | Look up a UMA address | | `grid sandbox fund` | Fund a sandbox account | | `grid sandbox send` | Simulate sending funds to a JIT quote | | `grid sandbox receive` | Simulate receiving a UMA payment | Short aliases are available: `cust` for `customers`, `tx` for `transactions`, `acct` for `accounts`. ### Example: Send USDC to a Mexico Bank Account **1. Create a customer** ```bash theme={null} grid customers create \ --platform-id "your-id" \ --type INDIVIDUAL \ --full-name "Carlos Garcia" \ --birth-date "1988-03-20" \ --address-line1 "Av Reforma 222" \ --address-city "Mexico City" \ --address-state "CDMX" \ --address-postal "06600" \ --address-country "MX" ``` **2. Create an external CLABE account for the customer** ```bash theme={null} grid accounts external create \ --customer-id \ --currency MXN \ --account-type CLABE \ --clabe "012345678901234567" \ --beneficiary-type INDIVIDUAL \ --beneficiary-name "Carlos Garcia" \ --beneficiary-birth-date "1988-03-20" \ --beneficiary-nationality MX ``` **3. Create a quote** ```bash theme={null} grid quotes create \ --source-customer \ --source-currency USDC \ --dest-account \ --dest-currency MXN \ --amount 100000 \ --lock-side RECEIVING ``` **4. In sandbox, simulate the USDC deposit** ```bash theme={null} grid sandbox send --quote-id --currency USDC ``` **5. Check transaction status** ```bash theme={null} grid transactions get ``` > [!NOTE] > > All amounts are in the **smallest currency unit** (e.g., cents for USD, satoshis for BTC). Quotes expire in 1–5 minutes. # Confirm a customer's email verification code Source: https://agents-docs.lightspark.com/api-reference/contact-verification/confirm-a-customers-email-verification-code /openapi.yaml post /customers/{customerId}/verify-email/confirm Submit the one-time code the customer received by email to mark their email as `VERIFIED`. On success, `contactVerification.email` becomes `VERIFIED`. Both email and phone must be `VERIFIED` before the customer can begin KYC. In sandbox, the code is always `000000`. # Confirm a customer's phone verification code Source: https://agents-docs.lightspark.com/api-reference/contact-verification/confirm-a-customers-phone-verification-code /openapi.yaml post /customers/{customerId}/verify-phone/confirm Submit the one-time code the customer received via SMS to mark their phone as `VERIFIED`. On success, `contactVerification.phone` becomes `VERIFIED`. Both email and phone must be `VERIFIED` before the customer can begin KYC. In sandbox, the code is always `000000`. # Send a phone verification code to a customer Source: https://agents-docs.lightspark.com/api-reference/contact-verification/send-a-phone-verification-code-to-a-customer /openapi.yaml post /customers/{customerId}/verify-phone Send (or resend) a one-time code via SMS to the customer's phone number to begin phone verification. This endpoint is only meaningful for customers whose payment provider requires contact verification (e.g. EU customers); the customer's `contactVerification.phone` status reflects progress. For customers whose provider has no such requirement, this returns `409`. Call `POST /customers/{customerId}/verify-phone/confirm` with the received code to complete verification. Re-POST this endpoint to resend the code. In sandbox, no SMS is sent and the code is always `000000`. # Send an email verification code to a customer Source: https://agents-docs.lightspark.com/api-reference/contact-verification/send-an-email-verification-code-to-a-customer /openapi.yaml post /customers/{customerId}/verify-email Send (or resend) a one-time code to the customer's email address to begin email verification. This endpoint is only meaningful for customers whose payment provider requires contact verification (e.g. EU customers); the customer's `contactVerification.email` status reflects progress. For customers whose provider has no such requirement, this returns `409`. Call `POST /customers/{customerId}/verify-email/confirm` with the received code to complete verification. Re-POST this endpoint to resend the code. In sandbox, no email is sent and the code is always `000000`. # Create a transfer quote Source: https://agents-docs.lightspark.com/api-reference/cross-currency-transfers/create-a-transfer-quote /openapi.yaml post /quotes Generate a quote for a transfer between any combination of accounts and UMA addresses. This endpoint handles same-currency and cross-currency transfers alike, and provides the necessary instructions to execute the transfer. **Transfer Types Supported:** - **Account to Account**: Transfer between internal/external accounts, with or without currency exchange. - **Account to UMA**: Transfer from an internal account to an UMA address. - **UMA to Account or UMA to UMA**: This transfer type will only be funded by payment instructions, not from an internal account. - **Account or real-time funding to a Lightning invoice**: Pay a BOLT11 invoice saved as a `LIGHTNING` external account with `invoice`. Available on platforms with Lightning invoice sends enabled; see "Paying a Lightning invoice" below. **Key Features:** - **Flexible Amount Locking**: Always specify whether you want to lock the sending amount or receiving amount - **Currency Exchange**: Handles all cross-currency transfers with real-time exchange rates - **Payment Instructions**: For UMA or customer ID sources, provides banking details needed for execution **Same-currency transfers:** Use this endpoint for same-currency transfers too. Set `immediatelyExecute: true` to create and execute in a single request. The older `/transfer-in` and `/transfer-out` endpoints are deprecated. Requires a token with the `TRANSACT` permission; `VIEW` alone is not sufficient. A quote is the instrument a later execute draws on, and `immediatelyExecute` moves funds within this same request. **Paying a Lightning invoice:** Quote a `LIGHTNING` account that has an `invoice` set, with `lockedCurrencySide` `RECEIVING` and `lockedCurrencyAmount` the invoice's amount in satoshis. The invoice must name an amount in whole satoshis, be for the platform's Bitcoin network, and not expire within the next minute. An invoice has one live quote at a time. # Execute a quote Source: https://agents-docs.lightspark.com/api-reference/cross-currency-transfers/execute-a-quote /openapi.yaml post /quotes/{quoteId}/execute Execute a quote by its ID. This endpoint initiates the transfer between the source and destination accounts. This endpoint can only be used for quotes with a `source` which is either an internal account, or has direct pull functionality (e.g. ACH pull with an external account). When the quote's `source` is an internal account of type `EMBEDDED_WALLET`, the request must include a `Grid-Wallet-Signature` header. The header value is the full Grid wallet signature built over the `payloadToSign` value from the quote's `paymentInstructions[].accountOrWalletInfo` entry with the session private key of a verified authentication credential on the source Embedded Wallet. Requires a token with the `TRANSACT` permission; `VIEW` alone is not sufficient to release a transfer. On an `EMBEDDED_WALLET` source this is in addition to the `Grid-Wallet-Signature` header: the signature proves the wallet holder authorized the payment, while `TRANSACT` is what authorizes your integration to release it. Once executed, the quote cannot be cancelled and the transfer will be processed. # Get a payment document Source: https://agents-docs.lightspark.com/api-reference/cross-currency-transfers/get-a-payment-document /openapi.yaml get /payment-documents/{paymentDocumentId} Retrieve a payment document by ID. Use it to check whether the document can still be used on a quote, or which quote it is attached to. # Get quote by ID Source: https://agents-docs.lightspark.com/api-reference/cross-currency-transfers/get-quote-by-id /openapi.yaml get /quotes/{quoteId} Retrieve a quote by its ID. If the quote has been settled, it will include the transaction ID. This allows clients to track the full lifecycle of a payment from quote creation to settlement. # Look up an external account for payment Source: https://agents-docs.lightspark.com/api-reference/cross-currency-transfers/look-up-an-external-account-for-payment /openapi.yaml get /receiver/external-account/{accountId} Lookup an external account by ID to determine supported currencies and exchange rates. This endpoint helps platforms determine what currencies they can send to a given external account, along with the current estimated exchange rates and minimum and maximum amounts that can be sent. # Look up an UMA address for payment Source: https://agents-docs.lightspark.com/api-reference/cross-currency-transfers/look-up-an-uma-address-for-payment /openapi.yaml get /receiver/uma/{receiverUmaAddress} Lookup a receiving UMA address to determine supported currencies and exchange rates. This endpoint helps platforms determine what currencies they can send to a given UMA address. # Upload a payment document Source: https://agents-docs.lightspark.com/api-reference/cross-currency-transfers/upload-a-payment-document /openapi.yaml post /payment-documents Upload a supporting document for a payout that requires one. [Supporting Documents](https://docs.lightspark.com/payouts-and-b2b/payment-flow/send-payment#supporting-documents) lists which payouts need documents, the documents each `purposeOfPayment` needs, and what the payout partner checks in them. Upload each file here, then pass the returned `id` values in `documentIds` on `POST /quotes`. Grid attaches the files to the payment before it returns the quote. The request must use multipart/form-data with the file in the `file` field and metadata in the remaining fields. Each request uploads one file. To upload several files, send one request per file. You can send them in parallel. A quote accepts up to 3 documents. Supported formats are PDF, JPEG, and PNG. Grid detects the format from the file contents, not the file name. The file must be from 1 to 8,000,000 bytes. An empty file, a larger file, and any other format return `400 INVALID_INPUT`. A payment document: - can be used until its `expiresAt`, 24 hours after upload - can be used on one quote only, and only on a quote for the customer in `customerId`, or on the platform's own quote when `customerId` is omitted - has its file deleted by Grid once it is attached to the payment Grid does not check what a document says. The payout partner reviews each document after Grid attaches it, and may reject or delay the payout. A successful attachment means the payout partner received the file, not that it approved it. # Add a new customer Source: https://agents-docs.lightspark.com/api-reference/customers/add-a-new-customer /openapi.yaml post /customers Register a new customer in the system with an account identifier and bank account information # Add an individual customer from a reusable KYC share token Source: https://agents-docs.lightspark.com/api-reference/customers/add-an-individual-customer-from-a-reusable-kyc-share-token /openapi.yaml post /customers/share Create an individual customer whose verification profile is built from a partner's shared data, including their identity document, so it does not need to be uploaded again. Fields sent in this request take priority over the shared data. Check the customer's verification status to address any KYC gaps and then submit the customer for verification using `POST /verifications`. # Delete customer by ID Source: https://agents-docs.lightspark.com/api-reference/customers/delete-customer-by-id /openapi.yaml delete /customers/{customerId} Delete a customer by their system-generated ID # Get bulk import job status Source: https://agents-docs.lightspark.com/api-reference/customers/get-bulk-import-job-status /openapi.yaml get /customers/bulk/jobs/{jobId} Retrieve the current status and results of a bulk customer import job. This endpoint can be used to track the progress of both CSV uploads. The response includes: - Overall job status - Progress statistics - Detailed error information for failed entries - Completion timestamp when finished # Get customer by ID Source: https://agents-docs.lightspark.com/api-reference/customers/get-customer-by-id /openapi.yaml get /customers/{customerId} Retrieve a customer by their system-generated ID # Get the current Grid End User Terms Source: https://agents-docs.lightspark.com/api-reference/customers/get-the-current-grid-end-user-terms /openapi.yaml get /customers/end-user-terms Deprecated; use `GET /customers/agreements`, which lists every agreement Grid supports rather than the End User Terms alone. This operation keeps its original single-document response, so existing integrations continue to work unchanged. # List customers Source: https://agents-docs.lightspark.com/api-reference/customers/list-customers /openapi.yaml get /customers Retrieve a list of customers with optional filtering parameters. Returns all customers that match the specified filters. If no filters are provided, returns all customers (paginated). # List the current Grid agreement documents Source: https://agents-docs.lightspark.com/api-reference/customers/list-the-current-grid-agreement-documents /openapi.yaml get /customers/agreements Retrieve the current version and Grid-hosted URL of every agreement Grid supports. Supply an entry's `version` as `termsVersion` when recording a customer's acceptance of that agreement. The list is a catalog of available agreements, not a list of the agreements a given customer is required to accept. # Update customer by ID Source: https://agents-docs.lightspark.com/api-reference/customers/update-customer-by-id /openapi.yaml patch /customers/{customerId} Update a customer's metadata by their system-generated ID. Most customer updates complete synchronously and return `200` with the updated customer. Changing `email` for a customer whose Embedded Wallet signs in with an `EMAIL_OTP` credential, or `phoneNumber` for one that signs in with an `SMS_OTP` credential, changes how the customer signs in. The customer must first prove they control the new contact: 1. Register the new contact with `POST /auth/credentials`, passing `email` or `phoneNumber`. Grid sends a one-time code to it and returns the replacement credential. 2. Verify that code with `POST /auth/credentials/{id}/verify`. Grid answers `204`. A replacement that is not verified within 24 hours expires. 3. Call `PATCH /customers/{customerId}` with only `customerType` and the new contact. Grid returns `202` with `payloadToSign`, `requestId`, and `expiresAt`. Send this soon after verifying: if the verification would lapse before the challenge does, Grid returns `AUTH_CREDENTIAL_VERIFICATION_EXPIRED` instead, and the customer must verify again. 4. Use the session API keypair of an active session on the same Embedded Wallet to build an API-key stamp over `payloadToSign`. Retry the same request with that full stamp as the `Grid-Wallet-Signature` header and the `requestId` echoed back as the `Request-Id` header. The signed retry returns `200` with the updated customer, and the replacement becomes the wallet's sign-in credential in place of the old one. If the change is still being applied, the retry returns `200` with a `WalletOperationProcessing` body; re-send the identical signed request until it returns the customer. A change to a sign-in contact without a verified replacement returns `400` with `AUTH_CREDENTIAL_VERIFICATION_REQUIRED`. Customers without an Embedded Wallet credential of that type change `email` or `phoneNumber` with a plain update. A business administrator's credentials (those with `adminReference`) are not tied to the customer's contact and keep their own email. Update `email` and `phoneNumber` in separate PATCH calls. A request that includes both fields is rejected. # Upload customers via CSV file Source: https://agents-docs.lightspark.com/api-reference/customers/upload-customers-via-csv-file /openapi.yaml post /customers/bulk/csv Upload a CSV file containing customer information for bulk creation. The CSV file should follow a specific format with required and optional columns based on customer type. ### CSV Format The CSV file should have the following columns: Required columns for all customers: - umaAddress: The customer's UMA address (e.g., $john.doe@uma.domain.com) - platformCustomerId: Your platform's unique identifier for the customer - customerType: Either "INDIVIDUAL" or "BUSINESS" Required columns for individual customers: - fullName: Individual's full name - birthDate: Date of birth in YYYY-MM-DD format - addressLine1: Street address line 1 - city: City - state: State/Province/Region - postalCode: Postal/ZIP code - country: Country code (ISO 3166-1 alpha-2) Required columns for business customers: - businessLegalName: Legal name of the business - addressLine1: Street address line 1 - city: City - state: State/Province/Region - postalCode: Postal/ZIP code - country: Country code (ISO 3166-1 alpha-2) Optional columns for all customers: - addressLine2: Street address line 2 - platformAccountId: Your platform's identifier for the bank account - description: Optional description for the customer Optional columns for individual customers: - email: Customer's email address Optional columns for business customers: - businessRegistrationNumber: Business registration number - businessTaxId: Tax identification number ### Example CSV ```csv umaAddress,platformCustomerId,customerType,fullName,birthDate,addressLine1,city,state,postalCode,country,platformAccountId,businessLegalName john.doe@uma.domain.com,customer123,INDIVIDUAL,John Doe,1990-01-15,123 Main St,San Francisco,CA,94105,US acme@uma.domain.com,biz456,BUSINESS,,,400 Commerce Way,Austin,TX,78701,US ``` The upload process is asynchronous and will return a job ID that can be used to track progress. You can monitor the job status using the `/customers/bulk/jobs/{jobId}` endpoint. # List available receiving institution names Source: https://agents-docs.lightspark.com/api-reference/discoveries/list-available-receiving-institution-names /openapi.yaml get /discoveries Retrieve available payment institution names for a given country and currency. Use this endpoint to look up supported banks and payment providers for a specific corridor. If no country and currency parameter are provided, all payment institutions will be returned The `bankName` field in each result is the value to pass as `bankName` when creating an external account via `POST /customers/external-accounts`. # Delete a document Source: https://agents-docs.lightspark.com/api-reference/documents/delete-a-document /openapi.yaml delete /documents/{documentId} Delete an uploaded document. This cannot be undone. Documents that have already been submitted for verification may not be deletable. # Get a document by ID Source: https://agents-docs.lightspark.com/api-reference/documents/get-a-document-by-id /openapi.yaml get /documents/{documentId} Retrieve details and metadata of a specific document by ID. # List documents Source: https://agents-docs.lightspark.com/api-reference/documents/list-documents /openapi.yaml get /documents Retrieve a list of documents with optional filtering by document holder. # Replace a document Source: https://agents-docs.lightspark.com/api-reference/documents/replace-a-document /openapi.yaml put /documents/{documentId} Replace an existing document with a new file and/or updated metadata. The request must use multipart/form-data. Use this when a stored document was rejected during review, which arrives as an entry in the verification's `errors` array rather than as an error on upload. Replacing marks the previously submitted file inactive, which a second `POST /documents` would not do: that leaves the rejected file active alongside the new one, and the rejection can carry. Call `POST /verifications` afterwards to start a new review, since existing errors persist until a new review produces a new verdict. A file rejected on upload with `422 DOCUMENT_REJECTED` never creates a document, so there is nothing to replace. Retry those with `POST /documents`. The request follows the same per-type field rules as `POST /documents`, except that `documentHolder` isn't sent. Grid overwrites every metadata field with the values in this request, so resend `documentNumber`, `issuingAuthority`, and `side` even if they haven't changed. Supported file types: `application/pdf`, `image/jpeg`, and `image/png`. Grid matches on the `Content-Type` of the multipart part, not the file extension. Any other type, and any file over 10 MB, returns `400 INVALID_INPUT`. Grid forwards the file to its verification provider, which screens it as the request is handled and can reject it with `422 DOCUMENT_REJECTED`. To pass that screen, a photo or scan of a document must: - show the whole document, with all four corners inside the frame and nothing overlapping an edge - be in focus and free of glare, so every field and the machine-readable zone can be read - be in color, not a black-and-white copy - be a photo or scan of the physical document, not a screen capture, and not retouched in an image editor - be unexpired # Upload a document Source: https://agents-docs.lightspark.com/api-reference/documents/upload-a-document /openapi.yaml post /documents Upload a verification document for a customer or beneficial owner. The request must use multipart/form-data with the file in the `file` field and metadata in the remaining fields. Every upload requires `file`, `documentType`, `country`, and `documentHolder`. Identity documents require more fields: | `documentType` | Also required | `side` | | -------------- | ------------- | ------ | | `PASSPORT` | `documentNumber`, `issuingAuthority` | Not allowed | | `DRIVERS_LICENSE` | `documentNumber`, `issuingAuthority` | Required | | `NATIONAL_ID` | `documentNumber`, `issuingAuthority` | Required | | `RESIDENCE_PERMIT` | `documentNumber`, `issuingAuthority` | Required | | Any other type | None | Optional | Where `side` is required, upload the `FRONT` and `BACK` as two separate documents. A driver's license issued in Brazil (`country` is `BR`) is the exception: upload it as one image and omit `side`. A missing required field, or a `side` that isn't allowed, returns `400 INVALID_INPUT`. Supported file types: `application/pdf`, `image/jpeg`, and `image/png`. Grid matches on the `Content-Type` of the multipart part, not the file extension. Any other type, and any file over 10 MB, returns `400 INVALID_INPUT`. Grid forwards the file to its verification provider, which screens it as the request is handled and can reject it with `422 DOCUMENT_REJECTED`. To pass that screen, a photo or scan of a document must: - show the whole document, with all four corners inside the frame and nothing overlapping an edge - be in focus and free of glare, so every field and the machine-readable zone can be read - be in color, not a black-and-white copy - be a photo or scan of the physical document, not a screen capture, and not retouched in an image editor - be unexpired # End a care chat Source: https://agents-docs.lightspark.com/api-reference/embedded-care/end-a-care-chat /openapi.yaml delete /care/chats/{chatId} Close a chat the cardholder has finished with or navigated away from. The conversation stops accepting messages; sending one afterwards is refused with `CARE_CHAT_ENDED`, and the cardholder starts a new chat by sending another message. No session token is required, even on a verified chat. Ending a chat touches no account data, and requiring a live session would mean a partner whose cardholder walked away could not close the chat without texting them another code first. The chat only has to belong to the calling platform. Closing a chat that is already closed is not an error: that is the state the caller asked for. Requires a token with the `VIEW` permission. # Send a care chat message Source: https://agents-docs.lightspark.com/api-reference/embedded-care/send-a-care-chat-message /openapi.yaml post /care/chats/{chatId}/messages Run one turn of the cardholder's conversation with the support agent, or, with an empty `message`, open the chat without saying anything. This is the entry point to the whole surface: a partner who has never called any other care endpoint starts here. **Render `redactedCustomerMessage` if present, then `reply` if present.** That one rule covers every call: an opening, a resume and a turn. `redactedCustomerMessage` is what the cardholder typed with card numbers and other sensitive digit strings masked. Render it, never your own copy of what they typed, which would put those digits back on their screen and into your logs. **One turn, synchronously.** With a non-empty `message` the call holds the connection until the agent has answered, and returns the answer in `reply`. An `Idempotency-Key` header is required, and it is the only way to recover an answer: nothing on this surface returns a transcript, so a reply that was produced but not delivered is reachable only by retrying under the same key, which returns the original reply, with the same `id`, and never a `sessionToken`. **A replay requires the same session token its original request required.** Replaying a turn that was sent while the chat could reach the account needs a valid `X-Ls-Care-Session` for this chat; without one it is refused with `401 CARE_SESSION_EXPIRED` and returns no reply. A replay of a turn that ran before the cardholder verified, such as the turn in which they sent their code, needs no token. A replay never returns a `sessionToken`, even if the original response carried one, and it never cancels a verification or sends a code. So a partner who lost the token cannot replay an account turn: send the cardholder's next message as a new message without a token, which verifies them again (the verification is cancelled, a new code is sent, and the agent asks for it). A replay is answered, under the same rule, even after the chat has ended. A retry sent while the original turn is still running is a replay too: it waits for that turn's answer rather than running a second turn. A turn that failed does not pin its key: the same key may be sent again and will run a fresh turn. **An empty or absent `message` opens the chat and runs no turn.** On a cardholder with no open chat it opens one, and `reply` carries the agent's opening line, which is what a chat window renders before anybody has typed. On a cardholder who already has one it resumes: the chat's id and where verification stands come back, and `reply` does not, because an opening line arriving mid-conversation reads as the agent introducing itself a second time. `id` and `redactedCustomerMessage` are absent on both, because nothing was typed. Either way no code is sent and no `Idempotency-Key` is needed or consumed, so a widget in a reconnect loop may still call it as often as it likes. The exception is an opening addressed to a `CareChat:` that has ended: it answers `409 CARE_CHAT_ENDED`, and the recovery is to address the cardholder as `Customer:`, which opens a new chat. **`chatEnded` says when to stop.** It is on every `200` body, true or false, and describes the chat once the call finished: true means the chat has ended and no further message will be accepted. The agent can end the chat itself when the cardholder is done; on that turn `chatEnded` is true and the closing line is in `reply`. It is the chat's state rather than what this call did, so a replay of that turn also says true. When it is true, stop sending to that chat. **Verification happens in the conversation, and is never a `401`.** A chat that cannot yet reach the account answers `200`, and the first message with text texts the cardholder a code; later messages on the same chat send no second one. The agent's own sentence in `reply` asks for the code, so a partner that only renders the conversation needs nothing else. `verificationRequired` and `codeLength` are optional hints for an app that wants a dedicated one-time-code input of the right length, marked as a one-time code so iOS and Android offer to fill it from the SMS. The app still sends the code as an ordinary message. **The cardholder completes verification by sending the code as an ordinary message, and that turn's response carries the session.** When the code is correct, the chat comes out of that turn able to reach the account, and the body of that turn carries `sessionToken` and `expiresAt`. No other turn carries them: a session is issued per verification, not per message. Store the token and send it from the next call onwards. A code that does not match is answered in `reply`, not as an error, and the cardholder can send it again. **A cardholder whose code never arrived asks the agent for another one**, in the conversation, and the agent sends a new code to the number on file. There is no separate route for sending a code. While the chat's verification is current, send the session token in the `X-Ls-Care-Session` header on every message. **A verification goes stale, and the agent then asks for a code again rather than the call failing.** A chat reaches the cardholder's account only while its verification is newer than the session lifetime, fifteen minutes in the default deployment, which is the same window a session token lives for, so in practice the chat and the token go stale together. Past that window the next message **sent without the header** is treated as an unverified one: a fresh code goes out, the body carries `verificationRequired: true`, and the agent's own `reply` asks the cardholder to send the code. The turn still runs and still answers. **A lost or expired session token verifies again rather than being refused.** A new message sent with no `X-Ls-Care-Session` header, or with a token past its expiry, on a chat whose verification is still current is treated the same way: the verification is cancelled before the turn runs, so the turn cannot reach the account, and the call then proceeds exactly as on a stale chat. A fresh code goes out, the agent asks the cardholder for it, and the turn in which they send it returns the next `sessionToken`. No special handling is needed: a client that lost its token simply keeps sending messages. Only a token that is malformed is refused with `CARE_SESSION_EXPIRED`, and a genuine token minted for another chat or another cardholder with `NOT_FOUND`, or for another platform with `CARE_SESSION_PLATFORM_MISMATCH`. Those are a bug in the caller rather than a lost session, and nothing on the chat changes. An empty message never reads the header at all, so a widget still holding a dead token can resume with one and be told where verification stands without texting anybody. Everything in the response except `chatEnded` describes the chat as of the moment the turn started. The agent can verify a cardholder during a turn, so a chat that this very call verified still reads `verificationRequired: true` here; decide that you hold a session by the presence of `sessionToken`. Requires a token with the `VIEW` and `MANAGE` permissions; `VIEW` alone is not sufficient, because the agent can freeze, unfreeze and replace the cardholder's cards. # Add an administrator credential Source: https://agents-docs.lightspark.com/api-reference/embedded-wallet-auth/add-an-administrator-credential /openapi.yaml post /auth/admin-credentials Add a credential for an additional administrator of a `BUSINESS` customer's Embedded Wallet. The new administrator gets their own `EMAIL_OTP` or `PASSKEY` credential, tagged with `adminReference`, and signs for the wallet with it as the owner does. The wallet stays non-custodial: Grid never adds an administrator on its own, and every step is authorised by a signature from an existing verified credential on the account (the owner's, or another administrator's). This is the only way an administrator adds a credential. An administrator's session cannot sign `POST /auth/credentials`, which Grid refuses with `403 FORBIDDEN`, so every credential belonging to an administrator carries their `adminReference` and removing them revokes it. An administrator added with another administrator's signature has their own `adminReference` and is removed separately. It takes two signed steps. The first call returns `202` with a `payloadToSign` that creates the administrator's wallet user; the signed retry returns another `202` whose payload grants that user the authority to sign for the wallet; the second signed retry returns `201` with the new `AuthMethod`. The administrator then signs in with `POST /auth/credentials/{id}/challenge` and `POST /auth/credentials/{id}/verify`. # Create a delegated signing key Source: https://agents-docs.lightspark.com/api-reference/embedded-wallet-auth/create-a-delegated-signing-key /openapi.yaml post /auth/delegated-keys Delegate Spark token-transaction signing authority for a card funding source or agent backed by an Embedded Wallet internal account to a Grid-custodied P-256 API key. Grid uses the requested owner and internal account to identify the wallet, generates the keypair server-side, creates an isolated signer identity holding the public key, then policies granting that identity signing and self-revocation authority. The private key is custodied by Grid and never returned. Both activities must be authorized by the wallet owner, so creation is a three-leg signed-retry flow: 1. Call `POST /auth/delegated-keys` with no signature headers. Grid generates the delegated keypair and the response is `202` with a `payloadToSign`, `requestId`, and `expiresAt`. 2. Use the session API keypair of a verified credential on the requested Embedded Wallet internal account to build an API-key stamp over `payloadToSign`, then retry the same request with that full stamp as the `Grid-Wallet-Signature` header and the `requestId` echoed back as the `Request-Id` header. The response is a second `202` with a new `payloadToSign`, `requestId`, and `expiresAt`. 3. Stamp the new `payloadToSign` with the same session keypair and retry once more with the new `Request-Id`. The signed retry returns `201` with the created `DelegatedKey` in `ACTIVE` status. For card keys, a provisioned `PENDING_AUTH` card using this funding source becomes `ACTIVE` automatically. Cards still provisioning remain `PROCESSING`; frozen and closed cards retain their status. The same request body must be sent on all three legs. A flow abandoned after the second leg leaves the key in `PENDING` status: the signer identity exists but holds no policies, so it cannot sign or revoke itself. Abandoned `PENDING` card keys do not block creating another delegated key. For an agent, a `PENDING` key blocks a new one until it is revoked with `DELETE /auth/delegated-keys/{id}`; revoking a `PENDING` key only discards Grid's key material. After activation, Grid uses the custodied key to authorize signing for the card's Embedded Wallet funding account or for policy-approved agent actions in place of a session keypair; the platform never handles the key material. Each card funding source may have at most one `ACTIVE` delegated key, and each agent and Embedded Wallet pair at most one unrevoked (`PENDING` or `ACTIVE`) delegated key; revoke the existing key before creating a new one. A delegated key authorizes raw-payload signing for the wallet and cannot be scoped to amounts or recipients by the public API. Revoke it with `DELETE /auth/delegated-keys/{id}` when no longer needed. # Create an authentication credential Source: https://agents-docs.lightspark.com/api-reference/embedded-wallet-auth/create-an-authentication-credential /openapi.yaml post /auth/credentials Register an authentication credential for an Embedded Wallet customer. Embedded Wallet internal accounts are initialized with an `EMAIL_OTP` credential tied to the customer email on the account. Use this endpoint to add another credential (`SMS_OTP`, `OAUTH`, or `PASSKEY`), or to add `EMAIL_OTP` / `SMS_OTP` back after it has been removed. The account holder may have one `EMAIL_OTP` and one `SMS_OTP` credential per internal account; multiple distinct `PASSKEY` credentials may be registered. Administrators' credentials, added with `POST /auth/admin-credentials`, are not counted. Adding a credential requires a signature from an existing verified credential on the same account. An administrator's session cannot sign it: a credential added here carries no `adminReference`, so it would outlive the administrator's removal. Grid refuses an administrator's signature with `403 FORBIDDEN`; administrators add their own credentials with `POST /auth/admin-credentials`. Call this endpoint with the new credential's details to receive `202` with `payloadToSign` and `requestId`. Use the session API keypair of an existing verified credential (the session signing key the client holds for it) to build an API-key stamp over `payloadToSign`, then retry the same request with that full stamp as the `Grid-Wallet-Signature` header and the `requestId` echoed back as the `Request-Id` header. The signed retry returns `201` with the created `AuthMethod`. For OTP credentials, the one-time password is triggered on the signed retry, and the credential must then be activated via `POST /auth/credentials/{id}/verify`. # Get a delegated signing key Source: https://agents-docs.lightspark.com/api-reference/embedded-wallet-auth/get-a-delegated-signing-key /openapi.yaml get /auth/delegated-keys/{id} Retrieve a delegated signing key by its system-generated id. # List active sessions Source: https://agents-docs.lightspark.com/api-reference/embedded-wallet-auth/list-active-sessions /openapi.yaml get /auth/sessions Retrieve all active authentication sessions on an Embedded Wallet internal account. A session is created each time a credential is verified via `POST /auth/credentials/{id}/verify`, and remains active until its `expiresAt` passes or it is revoked via `DELETE /auth/sessions/{id}`. The response is not paginated: an internal account is expected to have a small, bounded number of concurrent sessions (one per signed-in device, typically 1–4), so all results are returned inline. # List authentication credentials Source: https://agents-docs.lightspark.com/api-reference/embedded-wallet-auth/list-authentication-credentials /openapi.yaml get /auth/credentials Retrieve all authentication credentials registered on an Embedded Wallet internal account. The response is not paginated: an internal account is expected to have a small, bounded number of credentials (typically 1–5), so all results are returned inline. Additional per-credential detail (such as active session expiry) is available on `GET /auth/sessions`. # List delegated signing keys Source: https://agents-docs.lightspark.com/api-reference/embedded-wallet-auth/list-delegated-signing-keys /openapi.yaml get /auth/delegated-keys List delegated signing keys for an Embedded Wallet internal account, a card funding source, or both, including `PENDING` keys (user created but policy leg never completed) and `REVOKED` keys. At least one of `accountId` or `fundingSourceId` must be supplied. # Re-issue an authentication credential challenge Source: https://agents-docs.lightspark.com/api-reference/embedded-wallet-auth/re-issue-an-authentication-credential-challenge /openapi.yaml post /auth/credentials/{id}/challenge Re-issue the challenge for an existing authentication credential. For `EMAIL_OTP` and `SMS_OTP` credentials, this triggers a new one-time password to the contact on file and returns a fresh `otpEncryptionTargetBundle` for the client to HPKE-encrypt the OTP attempt against. After the user receives the new OTP, build the `encryptedOtpBundle` under the new target bundle and call `POST /auth/credentials/{id}/verify` to begin the secure OTP login flow. `OAUTH` credentials do not have a challenge step. To authenticate or reauthenticate an OAuth credential, call `POST /auth/credentials/{id}/verify` with a fresh OIDC token and a `clientPublicKey`. For `PASSKEY` credentials, this issues a fresh Grid reauthentication challenge. The request body must carry the client's ephemeral `clientPublicKey` so Grid can bake it into the session-creation payload the returned challenge is computed from. Send it as a compressed P-256 key in lowercase hex (66 characters, `02` or `03` prefix). The client keeps the matching private key as the resulting session signing key. An uppercase or uncompressed key is rejected with `400 INVALID_INPUT`. The response is a `PasskeyAuthChallenge` — the passkey auth method fields plus the WebAuthn `credentialId`, new `challenge`, `requestId`, and `expiresAt`. The `challenge` value is the lowercase hex-encoded SHA-256 digest of the canonical session-creation body, not a base64url string; that body is returned as `payloadToSign` so the client can check what the passkey authorizes before signing. The client base64url-decodes `credentialId` for `allowCredentials[].id` and UTF-8 encodes `challenge` (for example, `new TextEncoder().encode(challenge)`) as the WebAuthn challenge in `navigator.credentials.get()`, then submits the resulting assertion to `POST /auth/credentials/{id}/verify` with `Request-Id: ` to receive a session. # Refresh an authentication session Source: https://agents-docs.lightspark.com/api-reference/embedded-wallet-auth/refresh-an-authentication-session /openapi.yaml post /auth/sessions/{id}/refresh Refresh an active Embedded Wallet auth session and create a new session signing key. Session refresh is a two-step signed-retry flow: 1. Call `POST /auth/sessions/{id}/refresh` with the request body `{ "clientPublicKey": "02..." }` and no signature headers. Send a freshly generated P-256 public key as a compressed key in lowercase hex (66 characters, `02` or `03` prefix) and retain its private key. Grid binds the supplied `clientPublicKey` into the session-refresh payload, persists it as a pending request, and returns `202` with `payloadToSign`, `requestId`, and `expiresAt`. 2. Sign `payloadToSign` with the current session signing key, then retry the same request with the full API-key stamp as `Grid-Wallet-Signature`, the `requestId` echoed back as `Request-Id`, and the same `clientPublicKey` in the request body. On success, Grid returns a new `AuthSession`. The client keeps the private key matching `clientPublicKey` as the new session signing key, and no key material is returned. An uppercase or uncompressed key is rejected with `400 INVALID_INPUT`. The original session must still be active on both steps so it can authorize the refresh. If the session has already expired, use the credential reauthentication flow instead. # Revoke a delegated signing key Source: https://agents-docs.lightspark.com/api-reference/embedded-wallet-auth/revoke-a-delegated-signing-key /openapi.yaml delete /auth/delegated-keys/{id} Revoke an `ACTIVE` delegated signing key, or an agent's `PENDING` key. Revoking a `PENDING` agent key only discards Grid's key material. An active card using this funding source becomes `PENDING_AUTH` with `statusReason: "DELEGATION_REQUIRED"`. Frozen and closed cards retain their status. Grid uses the custodied delegated key to authorize deleting its own signer identity. Deleting the identity also removes its API key, after which the delegated key can no longer sign. The response is `204` when revocation completes. The underlying signing policies are left in place. Their consensus references the now-deleted signer identity, so they can never authorize anything, and deleting them is unnecessary for correctness or security. # Revoke an administrator's credentials Source: https://agents-docs.lightspark.com/api-reference/embedded-wallet-auth/revoke-an-administrators-credentials /openapi.yaml delete /auth/admin-credentials/{adminReference} Revoke every credential carrying `adminReference` on the account, removing the administrator's wallet users. Authorised like the add: the first call returns `202` with a `payloadToSign` for an existing verified credential to stamp, and the signed retry returns `204`. Refused with `400` `INVALID_INPUT` when it would leave the account with no active credential. # Revoke an authentication credential Source: https://agents-docs.lightspark.com/api-reference/embedded-wallet-auth/revoke-an-authentication-credential /openapi.yaml delete /auth/credentials/{id} Revoke an authentication credential on an Embedded Wallet internal account. Revocation is a two-step flow because it must be authorized by a session on a *different* credential on the same internal account: 1. Call `DELETE /auth/credentials/{id}` with no headers. The response is `202` with a `payloadToSign`, `requestId`, and `expiresAt`. 2. Use the session API keypair of an existing verified credential on the same internal account — other than the one being revoked — to build an API-key stamp over `payloadToSign`, then retry the same `DELETE` request with that full stamp as the `Grid-Wallet-Signature` header and the `requestId` echoed back as the `Request-Id` header. The signed retry returns `204`. The account must retain at least one authentication credential; an account with only a single credential cannot use this endpoint to revoke it. An administrator's credential (one with `adminReference`) is revoked with `DELETE /auth/admin-credentials/{adminReference}` instead, and is refused here with `INVALID_INPUT`. # Revoke an authentication session Source: https://agents-docs.lightspark.com/api-reference/embedded-wallet-auth/revoke-an-authentication-session /openapi.yaml delete /auth/sessions/{id} Revoke an authentication session on an Embedded Wallet internal account. Revocation is a two-step signed-retry flow: 1. Call `DELETE /auth/sessions/{id}` with no headers. The response is `202` with a `payloadToSign`, `requestId`, and `expiresAt`. 2. Use the session API keypair of a verified session on the same internal account (this can be the session being revoked, for self-logout) to build an API-key stamp over `payloadToSign`, then retry the same `DELETE` request with that full stamp as the `Grid-Wallet-Signature` header and the `requestId` echoed back as the `Request-Id` header. The signed retry returns `204`. Sessions also expire on their own. `404` is returned whenever the `id` does not match an active session — whether the session was never issued, was already revoked by a prior call, or has expired past its `expiresAt`. The response code reflects the resource state, not an error in the client's flow: re-revoking an already-revoked or expired session is safe and idempotent at the user intent level. # Verify an authentication credential Source: https://agents-docs.lightspark.com/api-reference/embedded-wallet-auth/verify-an-authentication-credential /openapi.yaml post /auth/credentials/{id}/verify Complete the verification step for a previously created authentication credential and issue a session. For `EMAIL_OTP` and `SMS_OTP` credentials, submit the `encryptedOtpBundle` produced by HPKE-encrypting `{otp_code, public_key}` under the `otpEncryptionTargetBundle` returned from registration when present, or from `POST /auth/credentials/{id}/challenge` when registration omitted it or the OTP must be reissued. The server is a pass-through and never sees the plaintext OTP code. On success the response is `202` with a `payloadToSign` containing a login signing message bound to the client's TEK public key. Build an API-key stamp over the exact UTF-8 bytes of that string with the matching TEK private key, without parsing, re-serializing, or trimming it. Then retry the same request with the full stamp in `Grid-Wallet-Signature` and the `requestId` echoed in `Request-Id`. The signed retry returns `200` with the issued `AuthSession`. The TEK public key becomes the session API key on successful completion. In sandbox mode, the OTP flow runs real HPKE end-to-end against a sandbox enclave keypair — clients build a real `encryptedOtpBundle` against the sandbox `otpEncryptionTargetBundle` and stamp the returned `payloadToSign` with their TEK keypair. The only sandbox shortcut is the magic OTP code (`"000000"`) the user "receives" instead of a real email or SMS delivery. For `OAUTH` credentials, supply a fresh OIDC token (`iat` must be less than 60 seconds before the request) along with the client-generated public key; this is also the reauthentication path after a prior session expired. The token identity (`iss`, `aud`, and `sub`) must match the OAuth credential being verified. Send `clientPublicKey` as a compressed P-256 key in lowercase hex (66 characters, `02` or `03` prefix). In sandbox, the token's `nonce` must equal the lowercase hex SHA-256 digest of the `clientPublicKey` string exactly as sent. For `PASSKEY` credentials, the client completes a WebAuthn assertion (`navigator.credentials.get()`) against the Grid-issued `challenge` returned from `POST /auth/credentials/{id}/challenge`, and submits the resulting `assertion` with the `Request-Id` header. The `clientPublicKey` for `PASSKEY` credentials is supplied on the challenge call, where it is bound into the pending session-creation request. On success for `OAUTH` and `PASSKEY`, and on the signed retry for OTP credentials, the response contains an `AuthSession`. The client already holds the session signing key, which is the private key it generated before authentication. No key material is returned. An uppercase or uncompressed `clientPublicKey` is rejected with `400 INVALID_INPUT`. The `expiresAt` timestamp marks when the session expires. # Environments Source: https://agents-docs.lightspark.com/api-reference/environments Grid provides two environments: production and sandbox. Both environments use the same base URL and API tokens are scoped to an environment. ## Base API URL `https://api.lightspark.com/grid/2025-10-13` ## Versioning The date in the base URL is the API's major version. Your integration pins a version through the URL it calls, and that version's behavior does not change underneath you. **Backwards-compatible changes.** We may extend the current version without changing the date: new endpoints, new optional request parameters, new response fields, and new enum values or webhook event types. Build your integration to ignore unrecognized response fields and to handle unfamiliar enum values gracefully. **Major version changes.** Breaking changes — removing or renaming fields or endpoints, changing types, or making optional parameters required — ship only in a new dated version. Releasing a new version does not affect existing ones; you upgrade by changing the date in the URL whenever you are ready. **Deprecation.** When we deprecate a version, we notify your account contact email with the migration steps and a deadline. The migration window is normally at least three months; updates critical to security or compliance may require faster action. After the window, deprecated versions are no longer supported. ## Sandbox Sandbox enables you to test your integration without making real payments. In sandbox, we expose sandbox specific APIs to trigger specific test cases like incoming payments. Additionally you'll find test UMA addresses to simulate different sending scenarios. For more information, see [Sandbox Testing](sandbox-testing). ## Production Production moves real money. To get access to a production environment, please reach out to your Lightspark contact. ## Service IPs Grid's API, webhook deliveries, and other outbound requests from Grid (such as calls to third-party services) use the following IP addresses. If you restrict traffic by IP, allowlist all three: * `52.42.15.30` * `34.216.87.164` * `44.226.21.146` > [!WARNING] > > These IPs are subject to change, but we will notify the account contact email before making any changes. # Get exchange rates Source: https://agents-docs.lightspark.com/api-reference/exchange-rates/get-exchange-rates /openapi.yaml get /exchange-rates Retrieve cached exchange rates for currency corridors. Returns FX rates that are cached for approximately 5 minutes. Rates include fees specific to your platform for authenticated requests. **Filtering Options:** - Filter by source currency to get all available destination corridors - Filter by specific destination currency or currencies - Provide a sending amount to get calculated receiving amounts Each rate reports whether its corridor is paying out now (`isAvailable`). # Add a new external account Source: https://agents-docs.lightspark.com/api-reference/external-accounts/add-a-new-external-account /openapi.yaml post /customers/external-accounts Register a new external bank account for a customer. # Add a new platform external account Source: https://agents-docs.lightspark.com/api-reference/external-accounts/add-a-new-platform-external-account /openapi.yaml post /platform/external-accounts Register a new external bank account for the platform. # Delete customer external account by ID Source: https://agents-docs.lightspark.com/api-reference/external-accounts/delete-customer-external-account-by-id /openapi.yaml delete /customers/external-accounts/{externalAccountId} Delete a customer external account by its system-generated ID. An account that is currently a trusted beneficiary for SCA cannot be deleted — untrust it first via `POST /customers/external-accounts/{externalAccountId}/untrust` (and its `/confirm`), then delete. # Delete platform external account by ID Source: https://agents-docs.lightspark.com/api-reference/external-accounts/delete-platform-external-account-by-id /openapi.yaml delete /platform/external-accounts/{externalAccountId} Delete a platform external account by its system-generated ID # Get customer external account by ID Source: https://agents-docs.lightspark.com/api-reference/external-accounts/get-customer-external-account-by-id /openapi.yaml get /customers/external-accounts/{externalAccountId} Retrieve a customer external account by its system-generated ID # Get platform external account by ID Source: https://agents-docs.lightspark.com/api-reference/external-accounts/get-platform-external-account-by-id /openapi.yaml get /platform/external-accounts/{externalAccountId} Retrieve a platform external account by its system-generated ID # List Customer external accounts Source: https://agents-docs.lightspark.com/api-reference/external-accounts/list-customer-external-accounts /openapi.yaml get /customers/external-accounts Retrieve a list of external accounts with optional filtering parameters. Returns all external accounts that match the specified filters. If no filters are provided, returns all external accounts (paginated). External accounts are bank accounts, cryptocurrency wallets, or other payment destinations that customers can use to receive funds from the platform. # List platform external accounts Source: https://agents-docs.lightspark.com/api-reference/external-accounts/list-platform-external-accounts /openapi.yaml get /platform/external-accounts Retrieve a list of all external accounts that belong to the platform, as opposed to an individual customer. These accounts are used for platform-wide operations such as receiving funds from external sources or managing platform-level payment destinations. # Start an ownership verification challenge Source: https://agents-docs.lightspark.com/api-reference/external-accounts/start-an-ownership-verification-challenge /openapi.yaml post /customers/external-accounts/{externalAccountId}/challenge Start (or restart) ownership verification for a `FIRST_PARTY` self-custody crypto wallet external account in `PENDING_OWNERSHIP_VERIFICATION` or `UNVERIFIED` status. The response carries the method-specific challenge material: - `WALLET_SIGNATURE` — a `messageToSign`; have the wallet sign it exactly and submit the result to the verify endpoint to complete verification synchronously. - `LIVENESS` — a hosted `verificationLink` (and possibly an embed `token`); the user completes a biometric flow and verification completes asynchronously. The outcome is delivered via `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks or by polling the account. Calling this endpoint again abandons any in-flight challenge and issues a new one with the requested method — use it to retry after a failed attempt, to replace an expired challenge, or to switch methods. An `UNVERIFIED` account returns to `PENDING_OWNERSHIP_VERIFICATION` when a new challenge is issued. Completing ownership verification moves the account to `ACTIVE`. # Start an ownership verification challenge Source: https://agents-docs.lightspark.com/api-reference/external-accounts/start-an-ownership-verification-challenge-1 /openapi.yaml post /platform/external-accounts/{externalAccountId}/challenge Start (or restart) ownership verification for a `FIRST_PARTY` self-custody crypto wallet external account in `PENDING_OWNERSHIP_VERIFICATION` or `UNVERIFIED` status. The response carries the method-specific challenge material: - `WALLET_SIGNATURE` — a `messageToSign`; have the wallet sign it exactly and submit the result to the verify endpoint to complete verification synchronously. - `LIVENESS` — a hosted `verificationLink` (and possibly an embed `token`); the user completes a biometric flow and verification completes asynchronously. The outcome is delivered via `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks or by polling the account. Calling this endpoint again abandons any in-flight challenge and issues a new one with the requested method — use it to retry after a failed attempt, to replace an expired challenge, or to switch methods. An `UNVERIFIED` account returns to `PENDING_OWNERSHIP_VERIFICATION` when a new challenge is issued. Completing ownership verification moves the account to `ACTIVE`. # Verify ownership with a wallet signature Source: https://agents-docs.lightspark.com/api-reference/external-accounts/verify-ownership-with-a-wallet-signature /openapi.yaml post /customers/external-accounts/{externalAccountId}/verify Complete a `WALLET_SIGNATURE` challenge by submitting the signature the wallet produced for the challenge's `messageToSign`. The message must be signed exactly as returned, and the signature must be submitted before the challenge's `expiresAt` — after expiry, start a new challenge. On success the account moves to `ACTIVE`; on an invalid signature it moves to `UNVERIFIED` (start a new challenge to retry). `LIVENESS` challenges complete asynchronously and never use this endpoint — their outcome is delivered via `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks or by polling the account. # Verify ownership with a wallet signature Source: https://agents-docs.lightspark.com/api-reference/external-accounts/verify-ownership-with-a-wallet-signature-1 /openapi.yaml post /platform/external-accounts/{externalAccountId}/verify Complete a `WALLET_SIGNATURE` challenge by submitting the signature the wallet produced for the challenge's `messageToSign`. The message must be signed exactly as returned, and the signature must be submitted before the challenge's `expiresAt` — after expiry, start a new challenge. On success the account moves to `ACTIVE`; on an invalid signature it moves to `UNVERIFIED` (start a new challenge to retry). `LIVENESS` challenges complete asynchronously and never use this endpoint — their outcome is delivered via `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks or by polling the account. # Authorize a rule-based account's SCA challenge Source: https://agents-docs.lightspark.com/api-reference/internal-accounts/authorize-a-rule-based-accounts-sca-challenge /openapi.yaml post /internal-accounts/{id}/authorize Satisfy the Strong Customer Authentication challenge carried by a `RULE_BASED` internal account in `PENDING_AUTHORIZATION` status by submitting an `ScaAuthorization` proof. The challenge authorizes the account's sweep instruction once; later sweeps need no further authorization. On success the account is returned `ACTIVE` with its `fundingPaymentInstructions`, and an `INTERNAL_ACCOUNT.STATUS_UPDATED` webhook is sent. As with all SCA, check the returned account: if it is still `PENDING_AUTHORIZATION` it carries the next `scaChallenge` to authorize. This endpoint is only meaningful for customers in a region where SCA is required (e.g. EU). For other customers, and for accounts with no pending challenge, this returns `409`. In sandbox, the SMS code is always `123456`. # Create an internal account Source: https://agents-docs.lightspark.com/api-reference/internal-accounts/create-an-internal-account /openapi.yaml post /internal-accounts Create a rule-based internal account. A rule-based account is an additional account number for an account holder that already holds an account in the same currency. It carries a routing rule: every payment that settles into it is attributed to that account number and then swept to the rule's destination, so you can issue one number per payer and reconcile incoming payments without matching on remittance text. Only `RULE_BASED` accounts can be created here. `INTERNAL_FIAT`, `INTERNAL_CRYPTO`, and `EMBEDDED_WALLET` accounts are provisioned automatically when an account holder is created or approved. The response carries the account's own `fundingPaymentInstructions` — the routable number payers send to for a `USD` account, or one deposit address per supported network for a `USDC` or `USDT` account. Rule-based accounts are currently available in `USD`, `USDC`, and `USDT`, for a customer or for your platform itself. `USDC` and `USDT` rule-based accounts are enabled per platform. For customers in a region where Strong Customer Authentication is required (the EU), the create request returns `202` with the account in `PENDING_AUTHORIZATION` and an `scaChallenge`: the account holder authorizes the account's sweep instruction once with `POST /internal-accounts/{id}/authorize`, after which the account is `ACTIVE` and later sweeps need no further authorization. An external destination must already be a trusted beneficiary (`POST /customers/external-accounts/{externalAccountId}/trust`). Retrying the create request with the same `Idempotency-Key` while the account is still `PENDING_AUTHORIZATION` returns it with a live challenge, issuing a new one if the previous challenge expired. Sandbox platforms can create rule-based accounts and exercise the whole flow. The account number is generated locally rather than issued by a partner bank, and `POST /sandbox/internal-accounts/{accountId}/fund` stands in for a settled deposit — funding the account triggers its sweep just as a real deposit would. Send an `Idempotency-Key` header to make retries safe: a request carrying a key already used returns the account created by the first request with a `200` instead of a `201`. Reusing a key for a materially different account is rejected with `409`. Without a key a retry cannot be recognised, so each request creates its own account and mints its own account number. # Export internal account wallet credentials Source: https://agents-docs.lightspark.com/api-reference/internal-accounts/export-internal-account-wallet-credentials /openapi.yaml post /internal-accounts/{id}/export Export the wallet credentials of an Embedded Wallet internal account. The returned wallet credentials are HPKE-encrypted to the `clientPublicKey` supplied in the request body. Export is a two-step signed-retry flow (same pattern as add-additional credential, revoke credential, and revoke session): 1. Call `POST /internal-accounts/{id}/export` with the request body `{ "clientPublicKey": "..." }` and no signature headers. Grid binds the `clientPublicKey` into the `payloadToSign` it returns, so the subsequent stamp in `Grid-Wallet-Signature` commits to the target encryption key. The response is `202` with `payloadToSign`, `requestId`, and `expiresAt`. 2. Use the session API keypair of a verified authentication credential on the same internal account to build an API-key stamp over `payloadToSign`, then retry with that full stamp as the `Grid-Wallet-Signature` header and the `requestId` echoed back as the `Request-Id` header. The retry body must carry the **same** `clientPublicKey` submitted in step 1 — Grid rejects the retry with `401` if it disagrees with what was bound into `payloadToSign`. The signed retry returns `200` with `encryptedWalletCredentials`, which the client decrypts with the matching private key. The export may not settle within that request: an approval- or consensus-gated wallet-provider activity answers `200` with `status: "PROCESSING"` instead. The credentials are never stored server-side, so collecting them is the client's job — re-send the byte-identical signed retry (same headers, same body) until it returns `encryptedWalletCredentials`. The `Request-Id` challenge stays usable until an attempt actually delivers them, so a `PROCESSING` response never burns it; a delivered export does, and a later re-send is then rejected with `401`. Subscribe to `wallet_operation.completed` to learn when re-sending will succeed rather than polling blind. The `clientPublicKey` is ephemeral: generate a fresh P-256 keypair for this export and discard the private key after decrypting. Send it as an uncompressed key in lowercase hex (130 characters, `04` prefix). Do not reuse the keypair from a verify or refresh call. That private key is the session signing key. # List Customer internal accounts Source: https://agents-docs.lightspark.com/api-reference/internal-accounts/list-customer-internal-accounts /openapi.yaml get /customers/internal-accounts Retrieve a list of internal accounts with optional filtering parameters. Returns all internal accounts that match the specified filters. If no filters are provided, returns all internal accounts (paginated). Internal accounts are created automatically when a customer is created based on the platform configuration. # List platform internal accounts Source: https://agents-docs.lightspark.com/api-reference/internal-accounts/list-platform-internal-accounts /openapi.yaml get /platform/internal-accounts Retrieve a list of all internal accounts that belong to the platform, as opposed to an individual customer. These accounts are created automatically when the platform is configured for each supported currency. They can be used for things like distributing bitcoin rewards to customers, or for other platform-wide purposes. # Move a rule-based account's stranded funds Source: https://agents-docs.lightspark.com/api-reference/internal-accounts/move-a-rule-based-accounts-stranded-funds /openapi.yaml post /internal-accounts/{id}/stranded-funds/transfer Move funds a sweep converted but could not deliver (listed in the account's `strandedBalances`) to the account holder's own internal account in the same currency. The transfer is returned as a quote in `PENDING_AUTHORIZATION` carrying an `scaChallenge`. Authorize it with `POST /quotes/{quoteId}/authorize`, as for any other quote. Once it completes, the currency drops out of `strandedBalances` and an `INTERNAL_ACCOUNT.BALANCE_UPDATED` webhook is sent. Stranded funds only arise for customers in a region where SCA is required (the EU). For other accounts, or when nothing is stranded in that currency, this returns `409`. # Resend a rule-based account's SCA challenge code Source: https://agents-docs.lightspark.com/api-reference/internal-accounts/resend-a-rule-based-accounts-sca-challenge-code /openapi.yaml post /internal-accounts/{id}/authorize/resend Re-send the one-time code for a `PENDING_AUTHORIZATION` rule-based account whose `scaChallenge.factor` is `SMS_OTP`. The existing challenge is reused and its `scaChallenge.expiresAt` is **not** extended. Only meaningful for customers in a region where SCA is required (e.g. EU); a 409 is returned otherwise. `TOTP` and `PASSKEY` challenges cannot be re-sent and return 409. In sandbox, the code is always `123456`. # Update internal account Source: https://agents-docs.lightspark.com/api-reference/internal-accounts/update-internal-account /openapi.yaml patch /internal-accounts/{id} Update mutable fields on an internal account. Today this supports updating the wallet privacy setting for an Embedded Wallet internal account. Updating wallet privacy is a two-step signed-retry flow: 1. Call `PATCH /internal-accounts/{id}` with the request body `{ "privateEnabled": true }` and no signature headers. Grid returns `202` with `payloadToSign`, `requestId`, and `expiresAt`. 2. Use the session API keypair of a verified authentication credential on the same internal account to build an API-key stamp over `payloadToSign`, then retry with that full stamp as the `Grid-Wallet-Signature` header and the `requestId` echoed back as the `Request-Id` header. The retry body must carry the same update fields submitted in step 1. The signed retry returns `200` with the updated internal account. # Cancel an UMA invitation Source: https://agents-docs.lightspark.com/api-reference/invitations/cancel-an-uma-invitation /openapi.yaml post /invitations/{invitationCode}/cancel Cancel a pending UMA invitation. Only the inviter or platform can cancel an invitation. When an invitation is cancelled: 1. The invitation status changes from PENDING to CANCELLED 2. The invitation can no longer be claimed 3. The invitation URL will show as cancelled when accessed Only pending invitations can be cancelled. Attempting to cancel an invitation that is already claimed, expired, or cancelled will result in an error. # Claim an UMA invitation Source: https://agents-docs.lightspark.com/api-reference/invitations/claim-an-uma-invitation /openapi.yaml post /invitations/{invitationCode}/claim Claim an UMA invitation by associating it with an invitee UMA address. When an invitation is successfully claimed: 1. The invitation status changes from PENDING to CLAIMED 2. The invitee UMA address is associated with the invitation 3. An INVITATION_CLAIMED webhook is triggered to notify the platform that created the invitation This endpoint allows customers to accept invitations sent to them by other UMA customers. # Create an UMA invitation Source: https://agents-docs.lightspark.com/api-reference/invitations/create-an-uma-invitation /openapi.yaml post /invitations Create an UMA invitation from a given platform customer. # Get an UMA invitation by code Source: https://agents-docs.lightspark.com/api-reference/invitations/get-an-uma-invitation-by-code /openapi.yaml get /invitations/{invitationCode} Retrieve details about an UMA invitation by its invitation code. # Create a beneficial owner Source: https://agents-docs.lightspark.com/api-reference/kyckyb-verifications/create-a-beneficial-owner /openapi.yaml post /beneficial-owners Add a beneficial owner, director, or company officer to a business customer. The beneficial owner will go through KYC verification automatically. # Delete a beneficial owner Source: https://agents-docs.lightspark.com/api-reference/kyckyb-verifications/delete-a-beneficial-owner /openapi.yaml delete /beneficial-owners/{beneficialOwnerId} Remove a beneficial owner from a business customer. The owner's uploaded documents are deleted with them, and the customer's verification status is recomputed without this owner. The request is rejected while a verification review that includes this owner is in progress; wait for the review to finish and retry. # Generate a hosted KYC/KYB link for an existing customer Source: https://agents-docs.lightspark.com/api-reference/kyckyb-verifications/generate-a-hosted-kyckyb-link-for-an-existing-customer /openapi.yaml post /customers/{customerId}/kyc-link Generate a single-use hosted URL the customer can complete to verify their identity, and (where supported) a provider-specific `token` for embedding the verification flow directly via the provider's SDK. The customer must already exist — create them with `POST /customers` first. Calling this endpoint does not change the customer's verification status; the customer remains at their current status until they complete (or fail) the hosted flow. This endpoint generates the link for both customer types; `customerType` selects which flow the provider runs. `INDIVIDUAL` runs identity verification (KYC), tracked on `kycStatus`. `BUSINESS` runs business verification (KYB), tracked on `kybStatus` — the flow confirms the company details, collects formation, ownership, and proof-of-address documents, and gathers the control person and every beneficial owner holding 25% or more. Business information already supplied via `POST /customers` or `PATCH /customers/{customerId}` is prefilled, so send what you have before generating the link. The hosted link is one of two ways to verify a customer. To collect the data yourself instead, submit it through `POST /customers`, `POST /beneficial-owners` (business customers), and `POST /documents`, then call `POST /verifications`. Both paths produce the same status transitions and the same `CUSTOMER.KYC_*` / `CUSTOMER.KYB_*` webhooks. Each call returns a fresh link. Previously-issued links are not invalidated, but they remain single-use and will expire on their own. # Get a beneficial owner Source: https://agents-docs.lightspark.com/api-reference/kyckyb-verifications/get-a-beneficial-owner /openapi.yaml get /beneficial-owners/{beneficialOwnerId} Retrieve details of a specific beneficial owner by ID. # Get a verification Source: https://agents-docs.lightspark.com/api-reference/kyckyb-verifications/get-a-verification /openapi.yaml get /verifications/{verificationId} Retrieve details of a specific verification by ID. # List beneficial owners Source: https://agents-docs.lightspark.com/api-reference/kyckyb-verifications/list-beneficial-owners /openapi.yaml get /beneficial-owners Retrieve a list of beneficial owners for a business customer. # List verifications Source: https://agents-docs.lightspark.com/api-reference/kyckyb-verifications/list-verifications /openapi.yaml get /verifications Retrieve a list of verifications with optional filtering by customer ID and status. # Submit customer for verification Source: https://agents-docs.lightspark.com/api-reference/kyckyb-verifications/submit-customer-for-verification /openapi.yaml post /verifications Trigger KYC (individual) or KYB (business) verification for a customer. The response indicates whether all required information has been provided. If data is missing, the `errors` array describes exactly what needs to be supplied before verification can proceed. Call this endpoint again after resolving errors to re-submit. ### What to collect for KYB Before submitting a `BUSINESS` customer, collect the following via `POST /customers`, `POST /beneficial-owners`, and `POST /documents`: **Business identifying information** - Entity full legal name - Doing Business As (DBA) name, if applicable - Physical address — principal place of business - Countries of operation - Identification number — U.S. taxpayer identification number, or, for a foreign business without one, alternative government-issued documentation certifying the existence of the business **Ownership and control structure** — collected for **one control person** (an individual with significant responsibility to control, manage, or direct the legal entity) **and all beneficial owners** (every individual who owns 25% or more, directly or indirectly). For each, provide: - Full name - Date of birth - Address - Identification number: - U.S. persons — SSN or ITIN - Non-U.S. persons — one or more of: ITIN, passport (with country of issuance), alien identification card, or another government-issued photo ID evidencing nationality or residence **Required documents** - Company formation and existence documents (certificate of incorporation, articles of association, etc.) - Proof of ownership and control structure (organization and ownership chart, shareholder agreements, operating agreements, register of members, or certification of controlling person and beneficial owners) - Proof of address dated within the last 3 months (utility bill, bank statement, lease agreement, or official correspondence) - Tax ID or equivalent identifying-number documents - For non-U.S. beneficial owners — passport plus one additional government-issued ID # Update a beneficial owner Source: https://agents-docs.lightspark.com/api-reference/kyckyb-verifications/update-a-beneficial-owner /openapi.yaml patch /beneficial-owners/{beneficialOwnerId} Update details of a specific beneficial owner. Only provided fields are updated. # Cancel a payout run Source: https://agents-docs.lightspark.com/api-reference/payout-runs/cancel-a-payout-run /openapi.yaml post /payout-runs/{payoutRunId}/cancel Skip every row that has not started. A row whose payment has already started still settles, and its transaction is still reported on the run. # Create a payout run Source: https://agents-docs.lightspark.com/api-reference/payout-runs/create-a-payout-run /openapi.yaml post /payout-runs Pay many recipients from one internal account in one request. Grid validates every row before it stores the run and answers with the run and the rows it rejected. The accepted rows are then paid in the background, each through its own quote, so each emits the usual `OUTGOING_PAYMENT.*` webhooks. A `PAYOUT_RUN.*` webhook reports the run when no row is left to pay. **Funding:** the source must be an open `INTERNAL_FIAT` or `INTERNAL_CRYPTO` account of the platform or of one of its customers. **Validation:** a rejected row is returned with its `failureCode` and `failureReason` and stored as `FAILED`. The run pays the accepted rows unless `allOrNothing` is set, in which case any rejection fails the run and skips every row. Every rule is enforced again when a row is paid, so a row can still fail then, for example on insufficient funds. **Rows to a contact:** a row addressed to a phone number or email is accepted and held as `PENDING_CLAIM`. Grid does not pay it. **Rows to a Grid customer:** a `GRID_CUSTOMER` row pays an internal account on the platform, under the rules of a quote between internal accounts. Requires an API token with the `UMA_TRANSACT` permission. # Export a payout run for reconciliation Source: https://agents-docs.lightspark.com/api-reference/payout-runs/export-a-payout-run-for-reconciliation /openapi.yaml get /payout-runs/{payoutRunId}/export Every row of the run in submission order, each with the transaction it produced: what was sent and received, the fees, the rate, the provider's reference and why it failed. Amounts are in the smallest unit of their currency. The transaction fields are absent for a row that produced no transaction. # Get a payout run Source: https://agents-docs.lightspark.com/api-reference/payout-runs/get-a-payout-run /openapi.yaml get /payout-runs/{payoutRunId} Retrieve a payout run and its row counts. # List the rows of a payout run Source: https://agents-docs.lightspark.com/api-reference/payout-runs/list-the-rows-of-a-payout-run /openapi.yaml get /payout-runs/{payoutRunId}/rows The run's rows in the order they were submitted, rejected rows included. # List balance changes for a period Source: https://agents-docs.lightspark.com/api-reference/periodic-statements/list-balance-changes-for-a-period /openapi.yaml get /internal-accounts/{id}/balance-changes Every change to this account's balance in a window, in the order the money moved, with the opening and closing balances for that window in the same response. `GET /transactions` returns one row per transaction. This returns one row per change to the balance, and a transaction that moves the balance more than once produces more than one change: an ACH deposit and its later return are two, and a card purchase that clears in two parts is two. Each change's `transactionId` names its transaction, so fetch it from `GET /transactions/{transactionId}` for its type, counterparty, merchant and rail. **The identity to assert:** `openingBalance + Σ(data[].amount) == closingBalance`, summed over every page. The opening and closing balances describe the window rather than the page, so they are the same on every page. Page until `hasMore` is false, then assert the identity. `startDate` and `endDate` are instants in any timezone, UTC included, and the window is half-open: a change at exactly `endDate` belongs to the next window. Consecutive windows therefore tile with no gap and no overlap. A window whose card settlement has not closed is refused with `409 NOT_YET_AVAILABLE`, because its figures could still change. Retry once it has settled. A fee is already inside a change's `amount`. For the fee itself, read `fees` on the change's transaction, counting each transaction once. # Send a receipt for a periodic statement Source: https://agents-docs.lightspark.com/api-reference/periodic-statements/send-a-receipt-for-a-periodic-statement /openapi.yaml post /internal-accounts/{id}/confirm-statement Record your monthly receipt that you pulled and issued this account's periodic statement for a period. Grid stores it as the delivery record for that account and period. Send it once a month for each account, after you have issued the statement built from `GET /internal-accounts/{id}/balance-changes`. Send it within five days of the month's end for every customer account: Grid checks then that every account's statement was issued. `statementMonth` names the statement month as `YYYY-MM`, a calendar month in US Central time. It must be a month that has already begun. Sending a receipt is idempotent. The stored time is the first receipt's, so calling this again does not move it. # Get platform configuration Source: https://agents-docs.lightspark.com/api-reference/platform-configuration/get-platform-configuration /openapi.yaml get /config Retrieve the current platform configuration # Update platform configuration Source: https://agents-docs.lightspark.com/api-reference/platform-configuration/update-platform-configuration /openapi.yaml patch /config Update platform configuration settings. Configure platform-wide card velocity limits through `POST /card-velocity-limits` without cardId. cardConfigs contains card presentation, authentication, risk, and merchant category settings. Set `cardConfigs.allowedMccs` and `cardConfigs.blockedMccs` to restrict new authorizations on this platform's cards where Grid makes authorization decisions. Card restrictions and Lightspark's controls also apply. Set `cardConfigs.pinTargetOrigin` to the canonical HTTPS origin of your frontend before calling `GET /cards/{id}/pin-entry-url`. Omit the field to preserve its value or set it to `null` to clear it. Set `cardConfigs.realTimeAuthorization` to enable real-time card authorization: Grid sends a `CARD_AUTHORIZATION.REQUEST` decision request to your `webhookEndpoint` for each card authorization. # Create a transfer-in request Source: https://agents-docs.lightspark.com/api-reference/same-currency-transfers/create-a-transfer-in-request /openapi.yaml post /transfer-in **Deprecated. Use `POST /quotes` instead.** Same-currency transfers are now served by the quote endpoint. Create a quote with an external account source and an internal account destination and set `immediatelyExecute: true` to move the funds in a single request, exactly as this endpoint does. This endpoint continues to work and its request and response shapes are unchanged. To migrate a request to `POST /quotes`: - add `sourceType: ACCOUNT` to `source` and `destinationType: ACCOUNT` to `destination`; the account IDs are unchanged - rename `amount` to `lockedCurrencyAmount` and add `lockedCurrencySide: SENDING` - add `immediatelyExecute: true` to keep the single-request behavior The quote response is a `Quote` rather than a `Transaction`; read `transactionId` from it to track the resulting transaction. Transfer funds from an external account to an internal account for a specific customer. This endpoint should only be used for external account sources with pull functionality (e.g. ACH Pull). Otherwise, use the paymentInstructions on the internal account to deposit funds. # Create a transfer-out request Source: https://agents-docs.lightspark.com/api-reference/same-currency-transfers/create-a-transfer-out-request /openapi.yaml post /transfer-out **Deprecated. Use `POST /quotes` instead.** Same-currency transfers are now served by the quote endpoint. Create a quote with an internal account source and an external account destination and set `immediatelyExecute: true` to move the funds in a single request, exactly as this endpoint does. This endpoint continues to work and its request and response shapes are unchanged. To migrate a request to `POST /quotes`: - add `sourceType: ACCOUNT` to `source` and `destinationType: ACCOUNT` to `destination`; the account IDs and `destination.paymentRail` are unchanged - rename `amount` to `lockedCurrencyAmount` and add `lockedCurrencySide: SENDING` - `remittanceInformation` and `purposeOfPayment` carry over unchanged - add `immediatelyExecute: true` to keep the single-request behavior The quote response is a `Quote` rather than a `Transaction`; read `transactionId` from it to track the resulting transaction. Transfer funds from an internal account to an external account for a specific customer. # Sandbox Testing Source: https://agents-docs.lightspark.com/api-reference/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. ### Individual customer verification (KYC) The **last 3 characters** of the `fullName` on `POST /customers` (with `customerType: INDIVIDUAL`) decide the customer's `kycStatus`. When that outcome applies depends on your platform: * **Unregulated platforms** (the default, with **Skip verification paperwork** off) — the customer is created `UNVERIFIED` and stays there until you submit with `POST /verifications`. An incomplete submission returns `RESOLVE_ERRORS`; the suffix decides the result once the submission is complete. * **Regulated platforms**, and unregulated platforms with **Skip verification paperwork** turned on in the sandbox dashboard's Configuration — the suffix resolves the customer at creation, with no documents required. `002` and any value other than `001`/`003` are terminal there. `001` and `003` land at `PENDING` and `UNVERIFIED`, which you can still submit for verification — and that submission carries the full data and document requirements below. | Suffix | Resolved `kycStatus` | Behavior on `POST /verifications` | | - | - | - | | **001** | `PENDING` | With complete data and an identity document: `verificationStatus: PENDING_MANUAL_REVIEW` — simulates an applicant waiting on manual review | | **002** | `REJECTED` | Returns `400` once resolved — the customer is terminally rejected | | **003** | `UNVERIFIED` | With complete data: `verificationStatus: RESOLVE_ERRORS` with a `POOR_QUALITY_DOCUMENT` error — simulates a correctable provider rejection | | **Any other** | `APPROVED` | Returns `400` once resolved — the customer is approved | Once a customer reaches `APPROVED` or `REJECTED`, that status is final: further `POST /verifications` calls return `400`, and renaming the customer does not re-derive it. Create a new customer when you want to exercise a different outcome. Where the suffix resolves at creation, only `001` and `003` customers can be submitted for verification. Use any non-suffixed name to mint ready-to-transact approved customers, and a `001` name to exercise the full submission flow. Where a submission is required — the default unregulated path, and `001`/`003` on any path — it carries the same data requirements as production: full name, birth date, nationality, `idType` + `identifier`, address, and an identity document uploaded via `POST /documents`. Anything missing comes back as `verificationStatus: RESOLVE_ERRORS` with one entry per problem in `errors`: ```json theme={null} { "verificationStatus": "RESOLVE_ERRORS", "errors": [ { "resourceId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "type": "MISSING_FIELD", "field": "identifier", "reason": "Identifier is required" }, { "resourceId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "type": "MISSING_IDENTITY_DOCUMENT", "acceptedDocumentTypes": ["PASSPORT", "DRIVERS_LICENSE", "NATIONAL_ID"], "reason": "Identity document is required" } ] } ``` Fix-and-resubmit example — the standard integration loop on the default unregulated path, with **Skip verification paperwork** off. A `001` suffix behaves the same way where the suffix resolves at creation, since `001` and `003` always require a complete packet; a terminal suffix on that path resolves without one, and `POST /verifications` then returns `400`. **1. Create a customer with a 001 suffix** A `fullName` ending in `001` leaves the customer submittable rather than resolving it terminally — `UNVERIFIED` on an unregulated platform, `PENDING` where the suffix resolves at creation. Persist the returned `id` for the following steps. ```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 '{ "customerType": "INDIVIDUAL", "fullName": "Jane Doe 001", "birthDate": "1990-01-15", "nationality": "US" }' ``` **2. Submit for verification** The first attempt returns `verificationStatus: RESOLVE_ERRORS` with every missing field and document listed in `errors`. ```bash theme={null} curl -X POST "https://api.lightspark.com/grid/2025-10-13/verifications" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerId": "Customer:..." }' ``` **3. Supply the missing fields and document** Patch the missing fields onto the customer, then upload the identity document. Any JPEG, PNG, or PDF works in sandbox. ```bash theme={null} curl -X PATCH "https://api.lightspark.com/grid/2025-10-13/customers/Customer:..." \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerType": "INDIVIDUAL", "idType": "SSN", "identifier": "111-22-3333", "address": { "line1": "123 Main St", "city": "Seattle", "state": "WA", "postalCode": "98101", "country": "US" } }' curl -X POST "https://api.lightspark.com/grid/2025-10-13/documents" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -F "documentHolder=Customer:..." \ -F "documentType=PASSPORT" \ -F "country=US" \ -F "documentNumber=A12345678" \ -F "issuingAuthority=U.S. Department of State" \ -F "file=@./passport.jpg" ``` **4. Resubmit** The verification now advances to `verificationStatus: PENDING_MANUAL_REVIEW` and the customer's `kycStatus` is `PENDING`. ```bash theme={null} curl -X POST "https://api.lightspark.com/grid/2025-10-13/verifications" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerId": "Customer:..." }' ``` ### Business customer verification (KYB) Business customers are always created with `kybStatus: UNVERIFIED`. The **last 3 characters** of `businessInfo.registrationNumber` decide what happens when the business is verified — whether you submit with `POST /verifications` or send the business through a hosted KYB link: | Suffix | Outcome | | - | - | | **003** | No auto-decision. Document and UBO verification run for real — the hosted flow asks for company documents and beneficial owners, and `POST /verifications` applies normal validation (`RESOLVE_ERRORS` until the data is complete). **Use this to test the link flow.** | | **001** | Same as `003` — no auto-decision, normal validation applies | | **002** | `kybStatus: REJECTED` (`verificationStatus: REJECTED`). Data and document validation is skipped only on a regulated platform or with **Skip verification paperwork** on; by default the packet is validated first | | **Any other** | `kybStatus: APPROVED` (`verificationStatus: APPROVED`). Data and document validation is skipped only on a regulated platform or with **Skip verification paperwork** on; by default the packet is validated first | Business customers never resolve at creation — the outcome lands on the first verification submission, whether you make it with `POST /verifications` or the applicant completes the hosted flow. What happens on that submission depends on your platform: * **Unregulated platforms** (the default, with **Skip verification paperwork** off) — the packet is validated first. A business with incomplete data returns `RESOLVE_ERRORS` whatever its suffix, and the suffix resolves only once the submission is complete. * **Regulated platforms**, and unregulated platforms with **Skip verification paperwork** on — a final suffix (`002` or any non-`001`/`003` value) resolves before the packet is validated, so it lands even on an incomplete submission. `001` and `003` still require a complete packet. > [!WARNING] > > Where a final suffix resolves without packet validation — a regulated platform, or **Skip verification paperwork** on — a registration number that doesn't end in `001`, `002`, or `003` is **auto-approved at its first submission**. The hosted flow then has nothing left to verify, so it asks for no documents and no beneficial owners — which looks exactly like a broken KYB flow but isn't. Always use a `003` suffix when you want to exercise document and UBO collection. Once a business customer is approved or rejected, further `POST /verifications` calls return `400`. For direct API onboarding, `001` and `003` both require complete business information, business documents, and at least one beneficial owner before `POST /verifications` moves past `RESOLVE_ERRORS`. On an unregulated platform every suffix goes through that check first, so an incomplete `002` business reports its missing data before it rejects. For the hosted flow, see [Configuring customers](/payouts-and-b2b/onboarding/configuring-customers#hosted-kyc-and-kyb-link-flow). ### Beneficial owner KYC When a business customer resolves terminally through its registration-number suffix, each of its beneficial owners is resolved at the same moment using the **last 3 characters** of the `lastName` in that owner's `personalInfo`: | 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`. 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. 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 | ### 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 | ## Funding an internal account Internal accounts are funded from their payment instructions or by pulling from an external account — see [Internal Accounts](/payouts-and-b2b/depositing-funds/internal-accounts) for both. Sandbox adds a shortcut that skips the wait: ```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 }' ``` To exercise the pull path instead, create a quote with an external account as the source and `immediatelyExecute` set to `true`. The account's suffix decides the outcome. ## Creating 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` | | **007** | Payout and refund failed | | **010** | Payout rejected by compliance screening — fails with `failureReason: COMPLIANCE_REJECTED` | | **011** | Invalid recipient account details — fails with `failureReason: ACCOUNT_INVALID` | | **012** | Recipient account cannot receive the payout — fails with `failureReason: ACCOUNT_CANNOT_RECEIVE` | | **013** | Transfer limit exceeded — fails with `failureReason: LIMIT_EXCEEDED` | | **Any other** | Successful payment | One suffix fails before any payment starts. Use it to test a corridor that is paused for maintenance: | Suffix | Behavior | | - | - | | **014** | Corridor paused for maintenance — the account lookup and the quote fail with `403 CORRIDOR_UNDER_MAINTENANCE`. Other accounts in the same currency work as normal | 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. ### 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 [funding an internal account](#funding-an-internal-account), 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" }' ``` ## 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 | | `$fail.payout.compliance.usd@sandbox.uma.money` | Payout fails with `failureReason: COMPLIANCE_REJECTED` | | `$fail.payout.accountinvalid.usd@sandbox.uma.money` | Payout fails with `failureReason: ACCOUNT_INVALID` | | `$fail.payout.cannotreceive.usd@sandbox.uma.money` | Payout fails with `failureReason: ACCOUNT_CANNOT_RECEIVE` | | `$fail.payout.limitexceeded.usd@sandbox.uma.money` | Payout fails with `failureReason: LIMIT_EXCEEDED` | ### 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 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 stamp the exact returned `payloadToSign` string 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 [Encrypt the OTP code](/global-accounts/client-keys#encrypt-the-otp-code-email_otp-only) 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. **1. Create a WebAuthn credential** Generate your own WebAuthn registration challenge and call `navigator.credentials.create()`. **2. Register the passkey** Register the passkey with `POST /auth/credentials`, passing the challenge and attestation returned by the browser. **3. Request a challenge** Reauthenticate with `POST /auth/credentials/{id}/challenge`, passing the compressed P-256 `clientPublicKey` (lowercase hex) whose private key becomes the session signing key. **4. Run the browser assertion** Pass the returned `challenge` into `navigator.credentials.get()` using the returned `credentialId` in `allowCredentials`. **5. Verify the assertion** Verify with `POST /auth/credentials/{id}/verify`, passing the browser assertion and echoing `Request-Id` from the challenge response. The sandbox validates the registered credential ID, WebAuthn challenge, origin/RP binding, user-presence bit, assertion signature, and signature counter. A successful verify response returns the session, just like production. The private key matching the `clientPublicKey` is the session signing key. ```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": "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" }' # 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": "..." } }' ``` ### 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 the lowercase hex SHA-256 digest of `clientPublicKey`, hashed as the exact string sent in the verify request. That string is the compressed public key in lowercase hex: 66 characters starting with `02` or `03`. ```bash theme={null} export PUBLIC_KEY="02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" OIDC_TOKEN=$(node - <<'NODE' const crypto = require("crypto"); const publicKey = process.env.PUBLIC_KEY || "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31"; 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. ### Wallet signature header For `PASSKEY` and `OAUTH` credentials and session refresh, the private key matching the `clientPublicKey` you supplied is the session signing key. For `EMAIL_OTP`, the TEK private key you generated for the encrypted OTP flow is the session signing key. 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. ```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. # Send a test webhook Source: https://agents-docs.lightspark.com/api-reference/sandbox/send-a-test-webhook /openapi.yaml post /webhooks/test Deliver a signed test event to the platform's configured webhook endpoint and return the endpoint's response. Available in sandbox and production. # Simulate a card authorization Source: https://agents-docs.lightspark.com/api-reference/sandbox/simulate-a-card-authorization /openapi.yaml post /sandbox/cards/{id}/simulate/authorization Simulate an inbound card authorization in the sandbox environment. Drives the same internal `authorize` + `reconcile` paths the card issuer would call in production, so platforms can exercise Grid's decisioning + funding-source pull behavior end-to-end without an external network round-trip. The decisioning outcome is controlled by the last three characters of `merchant.descriptor`: | Suffix | Outcome | | ------ | ------- | | `002` | Declined, `cardDeclinedReason: INSUFFICIENT_FUNDS`, as production declines an authorization the funding source cannot cover | | `003` | Declined, `cardDeclinedReason: CARD_NOT_ACTIVE`. Use against a `FROZEN` card to verify it refuses authorizations | | `005` | Delayed pull (~30s) — exercises the `PENDING → CONFIRMED` path | | `006` | Pull succeeds but the confirmation event reports `FAILED` — exercises the high-urgency `EXCEPTION` alert | | any other | Approved | `merchant.descriptor` must be 1–25 characters — the width of the card network's acceptor-name field. A longer one is rejected with `400` `INVALID_INPUT`. Production returns `404` on this path. # Simulate a card authorization advice Source: https://agents-docs.lightspark.com/api-reference/sandbox/simulate-a-card-authorization-advice /openapi.yaml post /sandbox/cards/{id}/simulate/authorization_advice Simulate an `AUTHORIZATION_ADVICE` that re-sizes an existing open authorization on a `CardTransaction` in the sandbox environment. The `amount` is the new *total* authorized amount the advice re-sizes to. The parent transaction must be in `AUTHORIZED` or `PARTIALLY_SETTLED` state. The resulting card operation is delivered asynchronously via the issuer's events webhook. Production returns `404` on this path. # Simulate a card authorization expiry Source: https://agents-docs.lightspark.com/api-reference/sandbox/simulate-a-card-authorization-expiry /openapi.yaml post /sandbox/cards/{id}/simulate/authorization_expiry Simulate an authorization the merchant never followed through on, so the card network lets it lapse and releases the held funds. Use it to see how your integration handles a hold that quietly goes away instead of settling. The transaction must still be fully open: once any part of it has settled, it can no longer expire. Grid processes the expiry in the background. Once it lands, the transaction has `status: VOIDED` and Grid sends a `CARD_TRANSACTION.VOIDED` webhook. Production returns `404` on this path. # Simulate a card authorization reversal Source: https://agents-docs.lightspark.com/api-reference/sandbox/simulate-a-card-authorization-reversal /openapi.yaml post /sandbox/cards/{id}/simulate/authorization_reversal Simulate a merchant cancelling a card authorization before it settles — a hotel releasing a hold, or a store voiding a purchase that never went through. Use it to see how your integration handles an authorization that disappears without ever clearing. Send `amount` to reverse only part of the authorization, or leave it out to reverse all of it. The transaction must still be fully open: once any part of it has settled, it can no longer be reversed. Grid processes the reversal in the background. A full reversal sets `status: VOIDED` and sends a `CARD_TRANSACTION.VOIDED` webhook. A partial reversal leaves the transaction `AUTHORIZED` with a smaller hold. Production returns `404` on this path. # Simulate a card balance inquiry Source: https://agents-docs.lightspark.com/api-reference/sandbox/simulate-a-card-balance-inquiry /openapi.yaml post /sandbox/cards/{id}/simulate/balance_inquiry Simulate a balance-inquiry authorization against a card in the sandbox environment. A balance inquiry is always a `0`-amount authorization, so the request carries no `amount` — only the `merchant`. Drives the same internal paths the card issuer would call in production. The resulting card operation is delivered asynchronously via the issuer's events webhook. Production returns `404` on this path. # Simulate a card clearing Source: https://agents-docs.lightspark.com/api-reference/sandbox/simulate-a-card-clearing /openapi.yaml post /sandbox/cards/{id}/simulate/clearing Simulate a clearing (settlement) event against an existing `CardTransaction` in the sandbox environment. - A clearing `amount` greater than the authorized amount exercises the over-auth post-hoc-pull path (e.g. restaurant tip on top of a 20% over-auth). - A clearing `amount` of `0` exercises the `AUTHORIZATION_EXPIRY` path — the auth expires with no clearing posted. - Suffix-driven outcomes on the parent transaction's id govern whether the post-hoc pull succeeds (use the suffix table from `simulate/authorization` to construct deterministic test cases). Production returns `404` on this path. # Simulate a card credit authorization Source: https://agents-docs.lightspark.com/api-reference/sandbox/simulate-a-card-credit-authorization /openapi.yaml post /sandbox/cards/{id}/simulate/credit_authorization Simulate an inbound credit authorization (a merchant-initiated credit to the card, e.g. a refund pushed as an authorization) in the sandbox environment. Drives the same internal paths the card issuer would call in production. The resulting card operation is delivered asynchronously via the issuer's events webhook. As with `simulate/authorization`, the decisioning outcome is controlled by the last three characters of `merchant.descriptor` — see the `simulate/authorization` suffix table. Production returns `404` on this path. # Simulate a card credit authorization advice Source: https://agents-docs.lightspark.com/api-reference/sandbox/simulate-a-card-credit-authorization-advice /openapi.yaml post /sandbox/cards/{id}/simulate/credit_authorization_advice Simulate a network-initiated `CREDIT_AUTHORIZATION_ADVICE` against a card in the sandbox environment. Like `simulate/credit_authorization`, it is keyed by the card (PAN) and `merchant` rather than an existing transaction. Drives the same internal paths the card issuer would call in production. The resulting card operation is delivered asynchronously via the issuer's events webhook. As with `simulate/authorization`, the decisioning outcome is controlled by the last three characters of `merchant.descriptor` — see the `simulate/authorization` suffix table. Production returns `404` on this path. # Simulate a card financial authorization Source: https://agents-docs.lightspark.com/api-reference/sandbox/simulate-a-card-financial-authorization /openapi.yaml post /sandbox/cards/{id}/simulate/financial_authorization Simulate a single-message financial authorization (an authorization that clears in the same message, e.g. an ATM withdrawal or other dual-message-exempt flow) against a card in the sandbox environment. Drives the same internal paths the card issuer would call in production. The resulting card operation is delivered asynchronously via the issuer's events webhook. As with `simulate/authorization`, the decisioning outcome is controlled by the last three characters of `merchant.descriptor` — see the `simulate/authorization` suffix table. Production returns `404` on this path. # Simulate a card financial credit authorization Source: https://agents-docs.lightspark.com/api-reference/sandbox/simulate-a-card-financial-credit-authorization /openapi.yaml post /sandbox/cards/{id}/simulate/financial_credit_authorization Simulate a single-message financial credit authorization (a credit to the card that clears in the same message, e.g. an ATM deposit or push credit) against a card in the sandbox environment. Drives the same internal paths the card issuer would call in production. The resulting card operation is delivered asynchronously via the issuer's events webhook. As with `simulate/authorization`, the decisioning outcome is controlled by the last three characters of `merchant.descriptor` — see the `simulate/authorization` suffix table. Production returns `404` on this path. # Simulate a card return Source: https://agents-docs.lightspark.com/api-reference/sandbox/simulate-a-card-return /openapi.yaml post /sandbox/cards/{id}/simulate/return Simulate a merchant-initiated `RETURN` against an existing settled card transaction in the sandbox environment. Creates a `CardRefund` and posts the return as its own dated `CREDIT` `CardTransaction` linked to the purchase via `originalTransactionId`; the purchase keeps its `SETTLED` status, whether the return is full or partial. Production returns `404` on this path. # Simulate a card return reversal Source: https://agents-docs.lightspark.com/api-reference/sandbox/simulate-a-card-return-reversal /openapi.yaml post /sandbox/cards/{id}/simulate/return_reversal Simulate a `RETURN_REVERSAL` against an existing refund in the sandbox environment — reversing a previously posted return. Pass the refund row's own id (the `CREDIT` `CardTransaction` linked to the purchase via `originalTransactionId`) as `cardTransactionId`. The resulting card operation is delivered asynchronously via the issuer's events webhook. Production returns `404` on this path. # Simulate funding an internal account Source: https://agents-docs.lightspark.com/api-reference/sandbox/simulate-funding-an-internal-account /openapi.yaml post /sandbox/internal-accounts/{accountId}/fund Simulate receiving funds into an internal account in the sandbox environment. This is useful for testing scenarios where you need to add funds to a customer's or platform's internal account without going through a real bank transfer or following payment instructions. This endpoint is only for the sandbox environment and will fail for production platforms/keys. ### Funding a rule-based account Funding a `RULE_BASED` account triggers its sweep, exactly as a settled deposit does in production: the balance is swept to the rule's destination and the account is left at zero. A balance outside the corridor band moves to the account holder's own account in the same currency instead, and the transaction fails with `failureReason: SWEEP_AMOUNT_OUT_OF_RANGE` — fund a rule-based account with a very small amount to rehearse that path. Either way the rule-based account is left at zero; it never holds a balance. The sweep's transaction reports a placeholder ACH depositor as its `REALTIME_FUNDING` source and the funded account as its `ruleBasedAccountId`, matching what a production deposit reports. To simulate the information a depositor attaches to a payment, pass `remittanceInformation`. It appears on the swept transaction's `source` (see `remittanceInformation` in the request body for which field). `description` cannot be set when funding a rule-based account: the swept transaction's description comes from the account's sweep rule, exactly as it does for a production deposit. Sending it returns `400 INVALID_INPUT`. ### Funding other account types `description` sets the memo on the incoming transaction for the credit. With `remittanceInformation` set, the credit is reported as a `REALTIME_FUNDING` ACH deposit from a placeholder sandbox depositor, matching what a production bank deposit reports. The request is validated before any funds are credited, so a request rejected with `400` leaves the balance unchanged. Sandbox simulates the API contract and the routing decision, not the banking rails. The quote, the corridor bounds, the destination checks and the webhook are all real; the settlement legs behind them are not, so timings and failure modes that originate at a partner bank cannot be reproduced here. # Simulate payment send to test receiving an UMA payment Source: https://agents-docs.lightspark.com/api-reference/sandbox/simulate-payment-send-to-test-receiving-an-uma-payment /openapi.yaml post /sandbox/uma/receive Simulate sending payment from an sandbox uma address to a platform customer to test payment receive. This endpoint is only for the sandbox environment and will fail for production platforms/keys. # Simulate sending funds Source: https://agents-docs.lightspark.com/api-reference/sandbox/simulate-sending-funds /openapi.yaml post /sandbox/send Simulate sending funds to the bank account as instructed in the quote. This endpoint is only for the sandbox environment and will fail for production platforms/keys. # Cancel a held execution Source: https://agents-docs.lightspark.com/api-reference/settlement-rules/cancel-a-held-execution /openapi.yaml post /settlement-rules/{settlementRuleId}/executions/{executionId}/cancel Stop a `HELD` execution before it fires. It becomes `SKIPPED` with `CANCELLED`, nothing is paid, and the customer keeps the payment. Requires an API token with the `UMA_TRANSACT` permission. # Create a settlement rule Source: https://agents-docs.lightspark.com/api-reference/settlement-rules/create-a-settlement-rule /openapi.yaml post /settlement-rules A standing instruction on a customer's incoming payments. When a payment into one of the customer's internal accounts completes and matches the rule, Grid splits it by the rule's shares and pays each share from the account the payment credited, through a payout run. Each share sends the usual `OUTGOING_PAYMENT.*` webhooks. **Matching:** a payment matches when it is in the rule's currency and any selector in `match` matches it. A customer's rules are tried in ascending `priority`, which is unique among its rules that are not `DELETED`. The first `ACTIVE` rule that matches wins, so a payment pays out under at most one rule. **Shares:** a `PERCENT` share is the payment times `basisPoints` over 10,000, rounded down. A `FIXED` share is `amount`. A `REMAINDER` share is what every other share leaves, wherever its row is; there is at most one. Without one, the customer keeps the rest. Shares are paid, and listed, in row order. The percentages add up to at most 10,000, and to less than 10,000 with a `REMAINDER` share. When a payment is smaller than its fixed and percentage shares together, nothing is paid. **Destinations:** the payout run row destinations. An `ACTIVE` external account of the platform or of one of its customers. A contact, held for a claim as on a payout run. An internal account of the platform (`GRID_CUSTOMER`), paid as a transfer between internal accounts under the rules of a quote between them. **Limits:** a customer has at most 50 rules that are not `DELETED`, and a rule has at most 20 shares. Requires an API token with the `UMA_TRANSACT` permission. The rule's shares are paid as that token. Once the token is deleted, expires or loses the permission, the rule pays nothing more and is paused. # Delete a settlement rule Source: https://agents-docs.lightspark.com/api-reference/settlement-rules/delete-a-settlement-rule /openapi.yaml delete /settlement-rules/{settlementRuleId} The rule becomes `DELETED` and fires on no further payment. Its executions still held are skipped with `CANCELLED`; one already paying finishes. Deleting a deleted rule returns it unchanged. Requires an API token with the `UMA_TRANSACT` permission. # Get a settlement rule Source: https://agents-docs.lightspark.com/api-reference/settlement-rules/get-a-settlement-rule /openapi.yaml get /settlement-rules/{settlementRuleId} Retrieve a settlement rule and its shares. A deleted rule is returned with status `DELETED`. # List settlement rules Source: https://agents-docs.lightspark.com/api-reference/settlement-rules/list-settlement-rules /openapi.yaml get /settlement-rules The platform's settlement rules, oldest first, with their shares. # List the executions of a settlement rule Source: https://agents-docs.lightspark.com/api-reference/settlement-rules/list-the-executions-of-a-settlement-rule /openapi.yaml get /settlement-rules/{settlementRuleId}/executions The payments the rule fired on, newest first, each with what it became, the payout run that paid it and how long the payments took. # Preview a settlement rule Source: https://agents-docs.lightspark.com/api-reference/settlement-rules/preview-a-settlement-rule /openapi.yaml post /settlement-rules/preview What a rule, or a draft of one, would pay on a payment of `amount`, and which of the customer's recent payments it would have fired on. Nothing is created or paid. **splits** lists the rule's rows in order, each with what it would send, computed exactly as an execution computes it: a percentage rounds down, and the `REMAINDER` share takes what the others leave. A share of 0 is not sent. `ownerKeeps` is what stays with the customer. When the fixed and percentage shares add up to more than `amount`, nothing would be paid, every row's `amount` is 0 and `skipReason` is `AMOUNT_BELOW_RULE`. Fees are not estimated: each share is sent by its own quote, whose fees come off the share. **recentMatches** are the customer's completed bank and chain deposits from the last 30 days that the rule's currency and `match` select, newest first, at most 20, whatever the customer's other rules would have done with them. Grid looks at the customer's latest 200 such deposits. Use them to spot a pattern that selects payments it should not. Give either `settlementRuleId` or `draft`. # Update a settlement rule Source: https://agents-docs.lightspark.com/api-reference/settlement-rules/update-a-settlement-rule /openapi.yaml patch /settlement-rules/{settlementRuleId} Change the fields sent and leave the rest as they are. `splits` replaces every share, checked as on create. `limits` replaces both limits, so a limit left out of it is removed, and `null` removes both. A change applies to the payments whose execution has not yet fired, held ones included. Requires an API token with the `UMA_TRANSACT` permission. # Create a stablecoin burn operation Source: https://agents-docs.lightspark.com/api-reference/stablecoins/create-a-stablecoin-burn-operation /openapi.yaml post /stablecoins/{stablecoinId}/burns Create a provider-backed burn/redemption operation for a registered stablecoin. Burn sources and fiat destinations are normal Grid account objects; provider-specific Brale address ids are resolved and stored internally. External Spark wallet sources return funding instructions and remain pending until the provider reports funds received. # Create a stablecoin mint operation Source: https://agents-docs.lightspark.com/api-reference/stablecoins/create-a-stablecoin-mint-operation /openapi.yaml post /stablecoins/{stablecoinId}/mints Create a provider-backed mint operation for a registered stablecoin. Funding and destination accounts are normal Grid `ExternalAccount` objects; any provider-specific Brale address linkage is managed internally by Grid. Wire-funded mints return funding instructions and remain pending until the provider reports funds received. # Get a stablecoin Source: https://agents-docs.lightspark.com/api-reference/stablecoins/get-a-stablecoin /openapi.yaml get /stablecoins/{stablecoinId} Retrieve a single registered stablecoin by its Grid identifier. # Get a stablecoin issuer operation Source: https://agents-docs.lightspark.com/api-reference/stablecoins/get-a-stablecoin-issuer-operation /openapi.yaml get /stablecoin-operations/{stablecoinOperationId} Retrieve a single stablecoin issuer operation by its Grid identifier. # Get a stablecoin provider account link Source: https://agents-docs.lightspark.com/api-reference/stablecoins/get-a-stablecoin-provider-account-link /openapi.yaml get /stablecoin-provider-accounts/{stablecoinProviderAccountId} Retrieve a stablecoin provider account link by id for the authenticated platform. # Link a stablecoin provider account Source: https://agents-docs.lightspark.com/api-reference/stablecoins/link-a-stablecoin-provider-account /openapi.yaml post /stablecoin-provider-accounts Link provider API credentials for the authenticated platform. Provider credentials are account-scoped and can be reused for multiple stablecoins. Currently, the only supported provider is `BRALE`; the provider environment is derived from the authenticated Grid platform mode. # List stablecoin issuer operations Source: https://agents-docs.lightspark.com/api-reference/stablecoins/list-stablecoin-issuer-operations /openapi.yaml get /stablecoins/{stablecoinId}/operations List issuer mint and burn operations for a stablecoin, most recent first, with cursor pagination. # List stablecoin provider account links Source: https://agents-docs.lightspark.com/api-reference/stablecoins/list-stablecoin-provider-account-links /openapi.yaml get /stablecoin-provider-accounts Retrieve stablecoin provider account links for the authenticated platform. # List stablecoins Source: https://agents-docs.lightspark.com/api-reference/stablecoins/list-stablecoins /openapi.yaml get /stablecoins Retrieve stablecoins registered to the authenticated platform. # Register an existing provider-created stablecoin Source: https://agents-docs.lightspark.com/api-reference/stablecoins/register-an-existing-provider-created-stablecoin /openapi.yaml post /stablecoins Register an existing provider-created stablecoin as a Grid `Stablecoin`. This endpoint links provider token metadata to Grid; it does not create the token with the provider. Grid derives the active provider account link from the authenticated platform, provider, and provider environment. # Request Grid enablement for a stablecoin Source: https://agents-docs.lightspark.com/api-reference/stablecoins/request-grid-enablement-for-a-stablecoin /openapi.yaml post /stablecoins/{stablecoinId}/request-grid-enablement Ask Lightspark to approve a registered stablecoin as a Grid settlement currency. Grid enablement is a reviewed workflow rather than a self-service switch, so this endpoint only records the request: it moves `gridOperationsStatus` from `NOT_ENABLED` to `PENDING_APPROVAL` and returns the updated stablecoin. Lightspark drives the remaining `ENABLING` and `ENABLED` steps. The request is idempotent. Repeating it while the stablecoin is already `PENDING_APPROVAL` succeeds and changes nothing. A stablecoin in any other Grid operations status cannot be requested, and returns `409`. # Revoke a stablecoin provider account link Source: https://agents-docs.lightspark.com/api-reference/stablecoins/revoke-a-stablecoin-provider-account-link /openapi.yaml delete /stablecoin-provider-accounts/{stablecoinProviderAccountId} Revoke the provider credentials behind a stablecoin provider account link. The stored credential material is cleared and the link's `status` becomes `REVOKED`; the link itself remains readable. Stablecoins registered under a revoked link can no longer mint or burn, and registering a new stablecoin under it is rejected with `STABLECOIN_PROVIDER_ACCOUNT_REVOKED`. Linking the same provider account again reactivates the link with the new credentials. Revoking a link that is already revoked succeeds and changes nothing. # Authorize a quote's SCA challenge Source: https://agents-docs.lightspark.com/api-reference/strong-customer-authentication/authorize-a-quotes-sca-challenge /openapi.yaml post /quotes/{quoteId}/authorize Satisfy the Strong Customer Authentication challenge carried by a quote in `PENDING_AUTHORIZATION` status by submitting an `ScaAuthorization` proof. This is used for realtime-funding quotes: the quote is returned with an `scaChallenge` and **without** `paymentInstructions`; once authorized, the quote advances and its `paymentInstructions` are populated so the customer can fund the transfer. As with all SCA, a quote may require more than one authorization: after authorizing, if the quote is still `PENDING_AUTHORIZATION` it carries the next `scaChallenge` — authorize that too, repeating until it advances (see `ScaChallenge`). This endpoint is only meaningful for customers in a region where SCA is required (e.g. EU). For customers outside SCA-regulated regions, this returns `409`. In sandbox, the SMS code is always `000000`. # Complete a 2FA reset Source: https://agents-docs.lightspark.com/api-reference/strong-customer-authentication/complete-a-2fa-reset /openapi.yaml post /sca/factors/reset/{resetId}/complete Complete a 2FA reset once liveness has passed, clearing the lost factor so the customer can re-enroll. The body is required and must carry the end user's `endUserIpAddress`. For an `SMS_OTP` reset, also supply the new `mobile` number — completing the reset enrolls it as the customer's number; other factors need no `mobile`. This endpoint is only meaningful for customers in a region where SCA is required (e.g. EU). For customers outside SCA-regulated regions, this returns `409`. # Complete an SCA login Source: https://agents-docs.lightspark.com/api-reference/strong-customer-authentication/complete-an-sca-login /openapi.yaml post /sca/login/complete Finalize an SCA login by submitting the proof for the started factor (`code` for `SMS_OTP` / `TOTP`, or `passkeyAssertion` + `origin` for `PASSKEY`), echoing the `challengeId` for `SMS_OTP`. Returns the reported session status. This endpoint is only meaningful for customers in a region where SCA is required (e.g. EU). For customers outside SCA-regulated regions, this returns `409`. In sandbox, the SMS/TOTP code is always `000000`. # Confirm SCA factor enrollment Source: https://agents-docs.lightspark.com/api-reference/strong-customer-authentication/confirm-sca-factor-enrollment /openapi.yaml post /sca/factors/confirm Finalize the factor enrollment started by `POST /sca/factors`. The request body is discriminated by `type`: for `TOTP`, submit the shared `secret` from the start call plus the first `code`; for `PASSKEY`, submit the WebAuthn `credential` the device produced plus the `origin` it was produced against. The threaded secret/credential binds the confirmation to its enrollment, so no separate id is needed. A TOTP confirmation returns one-time recovery codes (shown once); a passkey confirmation returns the enrolled factor. This endpoint is only meaningful for customers in a region where SCA is required (e.g. EU). For customers outside SCA-regulated regions, this returns `409`. In sandbox, the TOTP code is always `000000`. # Confirm trusting a beneficiary Source: https://agents-docs.lightspark.com/api-reference/strong-customer-authentication/confirm-trusting-a-beneficiary /openapi.yaml post /customers/external-accounts/{externalAccountId}/trust/confirm Finalize trusting a beneficiary (identified by the `externalAccountId` in the path) by submitting the SCA proof (`code` for `SMS_OTP` / `TOTP`, or `passkeyAssertion` + `origin` for `PASSKEY`), echoing the `challengeId` when one was issued. Returns `trusted: true`. This endpoint is only meaningful for customers in a region where SCA is required (e.g. EU). For customers outside SCA-regulated regions, this returns `409`. In sandbox, the SMS/TOTP code is always `000000`. # Confirm untrusting a beneficiary Source: https://agents-docs.lightspark.com/api-reference/strong-customer-authentication/confirm-untrusting-a-beneficiary /openapi.yaml post /customers/external-accounts/{externalAccountId}/untrust/confirm Finalize untrusting a beneficiary (identified by the `externalAccountId` in the path) by submitting the SCA proof (`code` for `SMS_OTP` / `TOTP`, or `passkeyAssertion` + `origin` for `PASSKEY`), echoing the `challengeId` when one was issued. Returns `trusted: false`. This endpoint is only meaningful for customers in a region where SCA is required (e.g. EU). For customers outside SCA-regulated regions, this returns `409`. In sandbox, the SMS/TOTP code is always `000000`. # Delete an enrolled SCA factor Source: https://agents-docs.lightspark.com/api-reference/strong-customer-authentication/delete-an-enrolled-sca-factor /openapi.yaml delete /sca/factors/{credentialId} Delete an enrolled SCA factor by its credential id. Today only `PASSKEY` factors carry a `credentialId` and are deletable this way. This endpoint is only meaningful for customers in a region where SCA is required (e.g. EU). For customers outside SCA-regulated regions, this returns `409`. # Get 2FA reset status Source: https://agents-docs.lightspark.com/api-reference/strong-customer-authentication/get-2fa-reset-status /openapi.yaml get /sca/factors/reset/{resetId} Poll the status of an in-progress 2FA reset until it reaches the liveness-passed value, after which the reset can be completed. This endpoint is only meaningful for customers in a region where SCA is required (e.g. EU). For customers outside SCA-regulated regions, this returns `409`. # List enrolled SCA factors Source: https://agents-docs.lightspark.com/api-reference/strong-customer-authentication/list-enrolled-sca-factors /openapi.yaml get /sca/factors List the Strong Customer Authentication factors the customer has enrolled. This endpoint is only meaningful for customers in a region where SCA is required (e.g. EU). For customers outside SCA-regulated regions, this returns `409`. # Record a security event Source: https://agents-docs.lightspark.com/api-reference/strong-customer-authentication/record-a-security-event /openapi.yaml post /sca/record-event Record a client-side security-relevant event for the customer with Grid's risk engine (e.g. a sign-in, a sensitive view), to feed adaptive-authentication signals. This endpoint is only meaningful for customers in a region where SCA is required (e.g. EU). For customers outside SCA-regulated regions, this returns `409`. # Resend a quote's SCA challenge code Source: https://agents-docs.lightspark.com/api-reference/strong-customer-authentication/resend-a-quotes-sca-challenge-code /openapi.yaml post /quotes/{quoteId}/authorize/resend Re-send the one-time code for a realtime-funding quote in `PENDING_AUTHORIZATION` status whose `scaChallenge.factor` is `SMS_OTP`. The existing challenge is reused — no new challenge is issued, and its `scaChallenge.expiresAt` is **not** extended; once the challenge is past `expiresAt` it can no longer be authorized. Only meaningful for customers in a region where SCA is required (e.g. EU); a 409 is returned otherwise. `PASSKEY` challenges cannot be re-sent and return 409. In sandbox, the code is always `000000`. # Start a 2FA reset Source: https://agents-docs.lightspark.com/api-reference/strong-customer-authentication/start-a-2fa-reset /openapi.yaml post /sca/factors/reset Begin recovering a lost enrolled factor via a liveness-gated, poll-based flow. Opens the liveness check and returns a `resetId` plus the opaque liveness handles (`livenessAccessToken` / `verificationLink`) the end user completes it with. Poll `GET /sca/factors/reset/{resetId}` until liveness passes, then call the complete endpoint. This endpoint is only meaningful for customers in a region where SCA is required (e.g. EU). For customers outside SCA-regulated regions, this returns `409`. # Start an SCA login Source: https://agents-docs.lightspark.com/api-reference/strong-customer-authentication/start-an-sca-login /openapi.yaml post /sca/login/start Begin an SCA login for the customer with the chosen factor, opening the end-user SCA session (an exemption gating read / account access beyond the per-transaction window). Returns factor-specific material: `SMS_OTP` dispatches a code and returns a `challengeId` + `expiresAt`; `TOTP` returns only the factor (the customer reads the code from their app); `PASSKEY` returns WebAuthn `passkeyOptions`. Complete with `POST /sca/login/complete`. This endpoint is only meaningful for customers in a region where SCA is required (e.g. EU). For customers outside SCA-regulated regions, this returns `409`. # Start SCA factor enrollment Source: https://agents-docs.lightspark.com/api-reference/strong-customer-authentication/start-sca-factor-enrollment /openapi.yaml post /sca/factors Begin enrolling an SCA factor for the customer. Enrollment covers the explicit, opt-in factors a customer chooses to add — the request body's `type` selects `TOTP` or `PASSKEY`. Returns the factor-specific material needed to finish via `POST /sca/factors/confirm`. `SMS_OTP` is implicit and is not enrolled through this endpoint. Every customer in an SCA-regulated region has a verified phone number from customer creation (via the Contact Verification flows — `POST /customers/{customerId}/verify-phone` and `.../verify-phone/confirm`), so SMS is always available as a factor with no extra setup and appears among the customer's enrolled factors in `GET /sca/factors`. A customer may have **up to two passkeys**. Starting a passkey enrollment with two already enrolled returns `409` (`PASSKEY_ALREADY_ENROLLED`). Delete one via `DELETE /sca/factors/{credentialId}` first. This endpoint is only meaningful for customers in a region where SCA is required (e.g. EU). For customers outside SCA-regulated regions, this returns `409`. # Start trusting a beneficiary Source: https://agents-docs.lightspark.com/api-reference/strong-customer-authentication/start-trusting-a-beneficiary /openapi.yaml post /customers/external-accounts/{externalAccountId}/trust Begin trusting (whitelisting) an external account so future sends to it can skip the per-transaction SCA ceremony. Returns the `scaChallenge` to satisfy (when one is issued). Complete with `POST /customers/external-accounts/{externalAccountId}/trust/confirm`. This endpoint is only meaningful for customers in a region where SCA is required (e.g. EU). For customers outside SCA-regulated regions, this returns `409`. # Start untrusting a beneficiary Source: https://agents-docs.lightspark.com/api-reference/strong-customer-authentication/start-untrusting-a-beneficiary /openapi.yaml post /customers/external-accounts/{externalAccountId}/untrust Begin untrusting (removing the trusted mark from) an external account, so future sends to it are dynamically linked and require the per-transaction SCA ceremony again. Returns the `scaChallenge` to satisfy (when one is issued). Complete with `POST /customers/external-accounts/{externalAccountId}/untrust/confirm`. This endpoint is only meaningful for customers in a region where SCA is required (e.g. EU). For customers outside SCA-regulated regions, this returns `409`. # Core Concepts Source: https://agents-docs.lightspark.com/api-reference/terminology Core concepts and terminology for the Grid API There are several key entities in the Grid API: **Platform**, **Customers**, **Internal Accounts**, **External Accounts**, **Quotes**, **Transactions**, and **UMA Addresses**. ![Entity relationships diagram showing platform, customers, internal accounts, external accounts, transactions, and UMA addresses](https://mintcdn.com/agents-docs/qL8kqlSYbvexR8cg/images/entities-light.svg?fit=max\&auto=format\&n=qL8kqlSYbvexR8cg\&q=85\&s=e5d51454ebea529d7d9219a140ab7e1e) ## Businesses, People, and Accounts ### Platform Your **platform** is you! It's the top-level entity that integrates with the Grid API. The platform: * Has its own configuration (webhook endpoint, supported currencies, API tokens, etc.) * A platform can have many customers both business and individual * Manages multiple customers and their accounts * Can hold platform-owned internal accounts for settlement and liquidity management * Acts as the integration point between your application and the open Money Grid ### Customers **Customers** are your end users who send and receive payments through your platform. Each customer: * Can be an individual or business entity * Has a KYC/KYB status that determines their ability to transact. If you are a regulated financial institution, this will typically be `APPROVED` since you do the KYC/KYB yourself. * Is identified by both a system-generated ID and optionally your platform-specific customer ID * May have associated internal accounts and external accounts * May have a unique **UMA address** (e.g., `$john.doe@yourdomain.com`). If you don't assign an UMA address when creating a customer, they will be assigned a system-generated one. ### Internal Accounts **Internal accounts** are Grid-managed accounts that hold balances in specific currencies. They can belong to either: * **Platform internal accounts** - Owned by the platform for settlement, liquidity, and float management * **Customer internal accounts** - Associated with specific customers for holding funds Internal accounts: * Have balances in a single currency (USD, EUR, MXN, etc.) * Can be funded via bank transfers or crypto deposits using payment instructions * Are used as sources or destinations for transactions instantly 24/7/365 * Track available balance for sending payments or receiving funds > [!NOTE] > > **Global Accounts** are a special kind of internal account, powered by a self-custody embedded [Spark](https://spark.money) wallet that Grid provisions for your customers to hold a stablecoin balance. They behave like any other internal account on the way in, but every outbound transfer must be authorized by a customer signature. See [Global Accounts](/payouts-and-b2b/global-accounts/overview) for the full overview. ### External Accounts **External accounts** are traditional bank accounts, crypto wallets, or other payment instruments connected to customers for on-ramping or off-ramping funds. Each external account: * Are associated with a specific customer or the platform * Represents a real-world bank account (with routing number, account number, IBAN, etc.), wallet, or payment instrument * Has an associated beneficiary (individual or business) who receives payments from the customer or platform * Has a status indicating screening status (ACTIVE, PENDING, INACTIVE, etc.) * Can be used as a destination for quote-based transfers or same currency transfers like withdrawals * For pullable sources like debit cards or ACH pulls, an external account can be used as a source for transfers-in to fund internal accounts or to fund cross-border transfers via quotes. ## Entity Examples by Use Case Understanding how entities map to your specific use case helps clarify your integration architecture. Here are common examples: ### B2B Payouts Platform (e.g., Bill.com, Routable) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The payouts platform itself | Your company providing AP automation | | **Customer** | Businesses sending payments to vendors | Acme Corp (your client company) | | **External Account** | Vendors/suppliers receiving payments | Office supply vendor, freelance contractor | **Flow**: Acme Corp (customer) uses your platform to pay their vendor invoices → funds move from Acme's internal account → to vendor's external bank account ### Direct Rewards Platform (Platform-Funded Model) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The app paying rewards directly to users | Your cashback app | | **Customer** | (Not used in this model) | N/A | | **External Account** | End users' crypto wallets receiving rewards | Sarah's self-custody Bitcoin wallet | **Flow**: Your platform sends micro-payouts directly from platform internal accounts → to users' external crypto wallets at scale. Common for cashback apps where the platform earns affiliate commissions and shares them with users. ### White-Label Rewards Platform (Customer-Funded Model) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The rewards infrastructure provider | Your white-label rewards API | | **Customer** | Brands or merchants running reward campaigns | Nike, Starbucks | | **External Account** | End users' crypto wallets receiving rewards | Sarah's self-custody Bitcoin wallet | **Flow**: Nike (customer) funds their internal account → your platform sends rewards on their behalf → to users' external crypto wallets. Common for brand loyalty programs where merchants manage their own reward budgets. ### Remittance/P2P App (e.g., Wise, Remitly) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The remittance service | Your money transfer app | | **Customer** | Both sender and recipient of funds | Maria (sender in US), Juan (recipient in Mexico) | | **External Account** | Bank accounts for funding/receiving | Maria's US bank (funding), Juan's Mexican bank (receiving funds) | **Flow**: Maria (customer) funds transfer from her external account → to Juan (also a customer) → who receives funds in his external bank account. Alternatively, Maria could send to Juan's UMA address directly. ## Transactions and Addressing Entities ### Quotes **Quotes** provide locked-in exchange rates and payment instructions for transfers. A quote: * Specifies a source (internal account, customer ID, or the platform itself) and destination (internal/external account or UMA address) * Locks an exchange rate for a short period (typically 1-5 minutes) or can be immediately executed with the `immediatelyExecute` flag * Calculates total fees and amounts for currency conversion * Provides payment instructions for funding the transfer if needed, or can be funded via an internal account balance. * Must be executed before it expires * Creates a transaction when executed ### Transactions **Transactions** represent completed or in-progress payment transfers. Each transaction: * Has a type (INCOMING or OUTGOING), keyed on whether the destination is an internal account * Has a status (PENDING, COMPLETED, FAILED, etc.) * References a customer (sender for outgoing, recipient for incoming) or a platform internal account * Specifies source and destination (accounts or UMA addresses) * Includes amounts, currencies, and settlement information * May include counterparty information for compliance purposes if required by your platform configuration Transactions are created when a quote is executed, incoming or outgoing. This covers same-currency and cross-currency transfers alike. ### UMA Addresses (optional) **UMA addresses** are human-readable payment identifiers that follow the format `$username@domain.com`. They: * Uniquely identify entities on the Grid network * Enable sending and receiving payments across different platforms without knowing the recipient's underlying account details or personal information * Support currency negotiation and cross-border transfers * Work similar to email addresses but for payments * Are an optional UX improvement for some use cases. Use of UMA addresses is not required in order to use the Grid API. # Approve a pending incoming payment Source: https://agents-docs.lightspark.com/api-reference/transactions/approve-a-pending-incoming-payment /openapi.yaml post /transactions/{transactionId}/approve Approve a pending incoming payment that was previously acknowledged with a 202 response. This endpoint allows platforms to asynchronously approve payments after async processing. # Cancel a bank transfer Source: https://agents-docs.lightspark.com/api-reference/transactions/cancel-a-bank-transfer /openapi.yaml post /transactions/{transactionId}/cancel Request cancellation of a pending bank transfer — an ACH transfer (push or pull) or a wire — before it has settled, for example a payment or collection initiated outside of the receiving bank's processing window. Whether a transfer can still be cancelled is determined by the banking partner that is settling it: the request is forwarded to the partner's own cancellation facility, and a transfer that the partner has already processed (or that is otherwise past its cancellation window) cannot be cancelled. Cancellation applies to bank-rail transfers; requests for transaction types that cannot be cancelled are rejected. # Confirm receipt delivery Source: https://agents-docs.lightspark.com/api-reference/transactions/confirm-receipt-delivery /openapi.yaml post /transactions/{transactionId}/confirm Confirm that the platform delivered the transaction receipt to its customer. This confirmation is only necessary when the platform is contractually required to send a receipt. If `receiptDeliveryConfirmedAt` is omitted, the confirmation time is set to the current server time. Calling this endpoint again for the same transaction updates the stored confirmation time. # Get transaction by ID Source: https://agents-docs.lightspark.com/api-reference/transactions/get-transaction-by-id /openapi.yaml get /transactions/{transactionId} Retrieve detailed information about a specific transaction. # List transactions Source: https://agents-docs.lightspark.com/api-reference/transactions/list-transactions /openapi.yaml get /transactions Retrieve a paginated list of transactions with optional filtering. The transactions can be filtered by customer ID, platform customer ID, UMA address, date range, status, and transaction type. Card transactions are included and identified by `type: CARD`. In Sandbox this is how you discover a `CardTransaction` id after simulating an authorization — list the transactions, take the card transaction's `id`, and pass it as the `cardTransactionId` to the clearing and return simulate endpoints. # Reject a pending incoming payment Source: https://agents-docs.lightspark.com/api-reference/transactions/reject-a-pending-incoming-payment /openapi.yaml post /transactions/{transactionId}/reject Reject a pending incoming payment that was previously acknowledged with a 202 response. This endpoint allows platforms to asynchronously reject payments after additional processing. # Webhooks Source: https://agents-docs.lightspark.com/api-reference/webhooks Learn how to receive and verify webhook notifications from the Grid API All webhooks sent by the Grid API include a signature in the `X-Grid-Signature` header, which allows you to verify the authenticity of the webhook. This is critical for security, as it ensures that only legitimate webhooks from Grid are processed by your system. ## Signature Verification Process 1. **Obtain your Grid public key** * This is available in your [dashboard](https://app.lightspark.com) under Developers webhook settings. * The key is in PEM format and can be used with standard cryptographic libraries 2. **Verify incoming webhooks** * Extract the signature from the `X-Grid-Signature` header * Decode the base64 signature * Create a SHA-256 hash of the entire request body * Verify the signature using the Grid webhook public key and the hash * Only process the webhook if the signature verification succeeds ## Verification Examples ### Node.js Example ```javascript theme={null} const crypto = require('crypto'); const express = require('express'); const app = express(); // Your Grid public key provided during integration const GRID_WEBHOOK_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE... -----END PUBLIC KEY-----`; app.post('/webhooks/uma', (req, res) => { const signatureHeader = req.header('X-Grid-Signature'); if (!signatureHeader) { return res.status(401).json({ error: 'Signature missing' }); } try { let signature: Buffer; try { // Parse the signature as JSON. It's in the format {"v": "1", "s": "base64_signature"} const signatureObj = JSON.parse(signatureHeader); if (signatureObj.v && signatureObj.s) { // The signature is in the 's' field signature = Buffer.from(signatureObj.s, "base64"); } else { throw new Error("Invalid JSON signature format"); } } catch { // If JSON parsing fails, treat as direct base64 signature = Buffer.from(signatureHeader, "base64"); } // Create verifier with the public key and correct algorithm const verifier = crypto.createVerify("SHA256"); const payload = await request.text(); verifier.update(payload); verifier.end(); // Verify the signature using the webhook public key const isValid = verifier.verify( { key: GRID_WEBHOOK_PUBLIC_KEY, format: "pem", type: "spki", }, signature, ); if (!isValid) { return res.status(401).json({ error: 'Invalid signature' }); } // Webhook is verified, process it based on type const webhookData = req.body; if (webhookData.type.startsWith('INCOMING_PAYMENT.')) { // Process incoming payment webhook // ... } else if (webhookData.type.startsWith('OUTGOING_PAYMENT.')) { // Process outgoing payment webhook // ... } // Acknowledge receipt of the webhook return res.status(200).json({ received: true }); } catch (error) { console.error('Signature verification error:', error); return res.status(401).json({ error: 'Signature verification failed' }); } }); app.listen(3000, () => { console.log('Webhook server listening on port 3000'); }); ``` ### Python Example ```python theme={null} from cryptography.hazmat.primitives import serialization, hashes from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature from flask import Flask, request, jsonify import base64 app = Flask(__name__) # Your Grid public key provided during integration GRID_PUBLIC_KEY = """-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE... -----END PUBLIC KEY-----""" # Load the public key public_key = serialization.load_pem_public_key( GRID_PUBLIC_KEY.encode('utf-8') ) @app.route('/webhooks/uma', methods=['POST']) def handle_webhook(): # Get signature from header signature = request.headers.get('X-Grid-Signature') if not signature: return jsonify({'error': 'Signature missing'}), 401 try: # Get the raw request body request_body = request.get_data() # Create a SHA-256 hash of the request body hash_obj = hashes.Hash(hashes.SHA256()) hash_obj.update(request_body) digest = hash_obj.finalize() # Decode the base64 signature signature_bytes = base64.b64decode(signature) # Verify the signature try: public_key.verify( signature_bytes, request_body, ec.ECDSA(hashes.SHA256()) ) except Exception as e: return jsonify({'error': 'Invalid signature'}), 401 # Webhook is verified, process it based on type webhook_data = request.json if webhook_data['type'].startswith('INCOMING_PAYMENT.'): # Process incoming payment webhook # ... pass elif webhook_data['type'].startswith('OUTGOING_PAYMENT.'): # Process outgoing payment webhook # ... pass # Acknowledge receipt of the webhook return jsonify({'received': True}), 200 except Exception as e: print(f'Signature verification error: {e}') return jsonify({'error': 'Signature verification failed'}), 401 if __name__ == '__main__': app.run(port=3000) ``` ## Testing To test your webhook implementation, you can trigger a test webhook from the Grid dashboard. This will send a test webhook to the endpoint you provided during the integration process. The test webhook will also be sent automatically when you update your platform configuration with a new webhook URL. An example of the test webhook payload is shown below: ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000007", "type": "TEST", "timestamp": "2025-08-15T14:32:00Z", "data": {} } ``` You should verify the signature of the webhook using the Grid public key and the process outlined in the [Signature Verification Process](#signature-verification-process) section and then reply with a 200 OK response to acknowledge receipt of the webhook. ## Security Considerations * **Always verify signatures**: Never process webhooks without verifying their signatures. * **Use HTTPS**: Ensure your webhook endpoint uses HTTPS to prevent man-in-the-middle attacks. * **Implement idempotency**: Use the `id` field to prevent processing duplicate webhooks. * **Timeout handling**: Implement proper timeout handling and respond to webhooks promptly. ## Retry Policy The Grid API will retry webhooks with the following policy based on the webhook type: | Webhook Type | Retry Policy | Notes | | - | - | - | | `TEST` | No retries | Used for testing webhook configuration | | `OUTGOING_PAYMENT.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `INCOMING_PAYMENT.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on: 409 (duplicate webhook) or PENDING status since it is served as an approval mechanism in-flow | | `BULK_UPLOAD.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `WALLET_OPERATION.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `INVITATION.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CUSTOMER.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `ACCOUNT.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CARD.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CARD_TRANSACTION.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CARD_AUTHORIZATION.REQUEST` | No retries | Real-time decision request, sent only while `cardConfigs.realTimeAuthorization` is set | # Agent action pending approval webhook Source: https://agents-docs.lightspark.com/api-reference/webhooks/agent-action-pending-approval-webhook /openapi.yaml webhook agent-action Fired when an agent submits an action that requires platform approval before Grid will execute it. Use this to send a push notification to the customer so they can review and approve or reject the action in your app. This endpoint should be implemented by clients of the Grid API. ### Authentication The webhook includes a signature in the `X-Grid-Signature` header that allows you to verify that the webhook was sent by Grid. To verify the signature: 1. Get the Grid public key provided to you during integration 2. Decode the base64 signature from the header 3. Create a SHA-256 hash of the request body 4. Verify the signature using the public key and the hash If the signature verification succeeds, the webhook is authentic. If not, it should be rejected. The payload contains the full `AgentAction` — including the embedded quote, or the requested purchase card for `ISSUE_PURCHASE_CARD` — so you can render the approval UI without a second API call. Approve or reject via `POST /agents/{agentId}/actions/{actionId}/approve` or `POST /agents/{agentId}/actions/{actionId}/reject`. # Bulk upload status webhook Source: https://agents-docs.lightspark.com/api-reference/webhooks/bulk-upload-status-webhook /openapi.yaml webhook bulk-upload Webhook that is called when a bulk customer upload job completes or fails. This endpoint should be implemented by clients of the Grid API. ### Authentication The webhook includes a signature in the `X-Grid-Signature` header that allows you to verify that the webhook was sent by Grid. To verify the signature: 1. Get the Grid public key provided to you during integration 2. Decode the base64 signature from the header 3. Create a SHA-256 hash of the request body 4. Verify the signature using the public key and the hash If the signature verification succeeds, the webhook is authentic. If not, it should be rejected. This webhook is sent when a bulk upload job completes or fails, providing detailed information about the results. # Card authorization request Source: https://agents-docs.lightspark.com/api-reference/webhooks/card-authorization-request /openapi.yaml webhook card-authorization-request Real-time decision request for a card authorization. Grid sends it to the `webhookEndpoint` in your platform config while `cardConfigs.realTimeAuthorization` is set. Your response decides whether the authorization proceeds. This endpoint should be implemented by clients of the Grid API that want to make their own authorization decisions. ### When Grid sends it Grid runs its own checks first: card status, card and platform spend limits, merchant category rules, and network support. Grid sends this request only for authorizations those checks approve, so a declined authorization never reaches your endpoint. Balance inquiries are never sent. It covers only cards whose authorization decisions Grid makes. ### Responding Return HTTP `200` with a `CardAuthorizationDecision` body within 1.5 seconds. `decision: APPROVE` lets the authorization proceed. `decision: DECLINE` declines it, and Grid passes your `declineReason` to the card network. If your endpoint returns any other status or an invalid body, or does not respond in time, Grid applies the `timeoutBehavior` in `cardConfigs.realTimeAuthorization`. Grid does not retry this request. ### After you approve Grid reserves the authorized amount from the funding source after you approve. If the funding source cannot cover it, Grid still declines with `INSUFFICIENT_FUNDS`. The final outcome arrives as a `CARD_TRANSACTION.AUTHORIZED` or `CARD_TRANSACTION.DECLINED` webhook. A decline you return arrives there with `cardDeclinedReason: PLATFORM_DECLINED` and your `declineReason` in `platformDeclineReason`. ### Duplicates Grid can send the same authorization more than once if the card network retries it. Each delivery has a new `id`, but `data.transactionId` and `data.issuerTransactionToken` stay the same. Return the same decision for each. ### Authentication The request includes a signature in the `X-Grid-Signature` header that allows you to verify that the request was sent by Grid. To verify the signature: 1. Get the Grid public key provided to you during integration 2. Decode the base64 signature from the header 3. Create a SHA-256 hash of the request body 4. Verify the signature using the public key and the hash If the signature verification succeeds, the request is authentic. If not, reject it. Also reject a request whose `timestamp` is more than a few minutes old. # Card pin status change Source: https://agents-docs.lightspark.com/api-reference/webhooks/card-pin-status-change /openapi.yaml webhook card-pin-status-change Sent when Grid records a change to a card's PIN status, including changes detected after a hosted PIN submission, an API request, or a PIN becoming blocked. The data contains the updated Card resource and its new pinStatus. Initial card creation does not trigger this event. Changing an existing PIN while its status remains OK does not trigger it either; use the submission result to confirm that the PIN changed. Changes made outside the Grid API are detected asynchronously. The event describes the status Grid observed, not every intermediate PIN status. Delivery can be repeated or arrive out of order. Deduplicate by id and use data.updatedAt to avoid replacing newer card data with an older snapshot. Read pinStatus from GET /cards/{id} to retrieve the card's saved status. Verify the X-Grid-Signature header using your Grid webhook public key, as for other Grid webhooks. No PIN, encrypted PIN block, or PIN-entry session credential is included. # Card status change Source: https://agents-docs.lightspark.com/api-reference/webhooks/card-status-change /openapi.yaml webhook card-status-change Webhook that is called when a card's lifecycle status changes. Fires on `PROCESSING → ACTIVE | PENDING_AUTH`, `ACTIVE ⇄ PENDING_AUTH` as funding sources or delegated signing authorization change, on `PROCESSING → CLOSED (ISSUER_REJECTED)` when issuer provisioning fails, and on every subsequent `ACTIVE ⇄ FROZEN`, `PENDING_AUTH → FROZEN`, and `→ CLOSED` transition. This endpoint should be implemented by clients of the Grid API. ### Authentication The webhook includes a signature in the `X-Grid-Signature` header that allows you to verify that the webhook was sent by Grid. To verify the signature: 1. Get the Grid public key provided to you during integration 2. Decode the base64 signature from the header 3. Create a SHA-256 hash of the request body 4. Verify the signature using the public key and the hash If the signature verification succeeds, the webhook is authentic. If not, it should be rejected. # Card transaction Source: https://agents-docs.lightspark.com/api-reference/webhooks/card-transaction /openapi.yaml webhook card-transaction Webhook that is called on every lifecycle update of a card `CardTransaction`. Fires when an authorization is approved (`CARD_TRANSACTION.AUTHORIZED`), as clearings settle against it (`CARD_TRANSACTION.PARTIALLY_SETTLED`, `CARD_TRANSACTION.SETTLED`), when the authorization is declined (`CARD_TRANSACTION.DECLINED`), when an authorization is fully reversed or expires (`CARD_TRANSACTION.VOIDED`), and when a transaction settles to the card network but the funding-source pull fails (`CARD_TRANSACTION.EXCEPTION`). The payload carries the full `CardTransaction` resource. A merchant return is its own `CardTransaction` row (`direction: CREDIT`) linked to the purchase via `originalTransactionId`, and fires its own `CARD_TRANSACTION.*` deliveries on that row — the purchase row is not re-fired for the return. This endpoint should be implemented by clients of the Grid API. ### Authentication The webhook includes a signature in the `X-Grid-Signature` header that allows you to verify that the webhook was sent by Grid. To verify the signature: 1. Get the Grid public key provided to you during integration 2. Decode the base64 signature from the header 3. Create a SHA-256 hash of the request body 4. Verify the signature using the public key and the hash If the signature verification succeeds, the webhook is authentic. If not, it should be rejected. # Claimable payout webhook Source: https://agents-docs.lightspark.com/api-reference/webhooks/claimable-payout-webhook /openapi.yaml webhook claimable-payout Fired when a claimable payout is claimed, paid, expires or fails, so the platform can tell the sender. `CLAIMABLE_PAYOUT.EXPIRED` and `CLAIMABLE_PAYOUT.FAILED` mean the hold on the sender's wallet was released. This endpoint should be implemented by clients of the Grid API. ### Authentication The webhook includes a signature in the `X-Grid-Signature` header that allows you to verify that the webhook was sent by Grid. To verify the signature: 1. Get the Grid public key provided to you during integration 2. Decode the base64 signature from the header 3. Create a SHA-256 hash of the request body 4. Verify the signature using the public key and the hash If the signature verification succeeds, the webhook is authentic. If not, it should be rejected. # Customer status change Source: https://agents-docs.lightspark.com/api-reference/webhooks/customer-status-change /openapi.yaml webhook customer-update Webhook that is called when the status of a customer is updated, including KYC and KYB status changes. This endpoint should be implemented by clients of the Grid API. ### Authentication The webhook includes a signature in the `X-Grid-Signature` header that allows you to verify that the webhook was sent by Grid. To verify the signature: 1. Get the Grid API public key provided to you during integration 2. Decode the base64 signature from the header 3. Create a SHA-256 hash of the request body 4. Verify the signature using the public key and the hash If the signature verification succeeds, the webhook is authentic. If not, it should be rejected. # External account webhook Source: https://agents-docs.lightspark.com/api-reference/webhooks/external-account-webhook /openapi.yaml webhook external-account-status Webhook that is called whenever the status or the account details of an external account change. Status changes cover any transition between statuses (`PENDING`, `ACTIVE`, `UNDER_REVIEW`, `INACTIVE`, `PENDING_OWNERSHIP_VERIFICATION`, `UNVERIFIED`) — for example when an account under review becomes active, when ownership verification completes (`PENDING_OWNERSHIP_VERIFICATION` → `ACTIVE`), or when a verification attempt fails (`PENDING_OWNERSHIP_VERIFICATION` → `UNVERIFIED`). This endpoint should be implemented by clients of the Grid API. ### Authentication The webhook includes a signature in the `X-Grid-Signature` header that allows you to verify that the webhook was sent by Grid. To verify the signature: 1. Get the Grid public key provided to you during integration 2. Decode the base64 signature from the header 3. Create a SHA-256 hash of the request body 4. Verify the signature using the public key and the hash If the signature verification succeeds, the webhook is authentic. If not, it should be rejected. ### Event types - `EXTERNAL_ACCOUNT.STATUS_UPDATED` — Fired when the status of an external account changes. The `data` payload contains the full external account object. - `EXTERNAL_ACCOUNT.ACCOUNT_INFO_UPDATED` — Fired when Grid corrects an external account's `accountInfo`. Today this happens when the receiving bank sends an ACH notification of change (NOC) with corrected details, such as a new routing number, account number or bank account type. The account keeps its `id`, and later payments to it use the corrected details. The `data` payload contains the full external account object with the corrected `accountInfo`. Deliveries can arrive out of order, and the external account has no version field, so an older `STATUS_UPDATED` payload can carry the pre-correction details. Before you replace a stored copy of the account details, fetch the current account with `GET /customers/external-accounts/{externalAccountId}` (or the platform equivalent) and store that response. # Incoming payment webhook and approval mechanism Source: https://agents-docs.lightspark.com/api-reference/webhooks/incoming-payment-webhook-and-approval-mechanism /openapi.yaml webhook incoming-payment Webhook that is called when an incoming payment is received by a customer — over UMA to the customer's UMA address, or over a bank rail (e.g. a SWIFT wire to the customer's funding coordinates from `fundingPaymentInstructions`). This endpoint should be implemented by clients of the Grid API. ### Authentication The webhook includes a signature in the `X-Grid-Signature` header that allows you to verify that the webhook was sent by Grid. To verify the signature: 1. Get the Grid public key provided to you during integration 2. Decode the base64 signature from the header 3. Create a SHA-256 hash of the request body 4. Verify the signature using the public key and the hash If the signature verification succeeds, the webhook is authentic. If not, it should be rejected. ### Payment Approval Flow When a transaction has `status: "PENDING"`, this webhook serves as an approval mechanism: 1. The client should check the `counterpartyInformation` against their requirements 2. To APPROVE the payment synchronously, return a 200 OK response 3. To REJECT the payment, return a 403 Forbidden response with an Error object 4. To request more information, return a 422 Unprocessable Entity with specific missing fields 5. To process the payment asynchronously, return a 202 Accepted response and then call the `/transactions/{transactionId}/approve` or `/transactions/{transactionId}/reject` endpoint within 5 seconds. Note that synchronous approval/rejection is preferred where possible. The Grid system will proceed or cancel the payment based on your response. For transactions with other statuses (COMPLETED, FAILED, REFUNDED), this webhook is purely informational. An ACH deposit can be returned after `COMPLETED`, days after it settled. The return takes the funds back, so the payment moves to `FAILED` and `INCOMING_PAYMENT.FAILED` is sent with `source.railDetails.status` set to `RETURNED` and `returnedAt` set. If the return is dishonored and the funds come back, the payment moves to `COMPLETED` again and `INCOMING_PAYMENT.COMPLETED` is sent with `railDetails.status` set to `RETURN_DISHONORED` and `dishonoredAt` set. A contested dishonor sends `INCOMING_PAYMENT.FAILED` once more with `RETURN_CONTESTED`. Bank-rail incoming payments (e.g. SWIFT) are informational at every status, including PENDING: the funds have already moved interbank, so the approval mechanism and the 5-second response window do not apply. # Internal account status webhook Source: https://agents-docs.lightspark.com/api-reference/webhooks/internal-account-status-webhook /openapi.yaml webhook internal-account-status Webhook that is called when the status of an internal account changes. This includes balance updates and may include additional account events in the future. This endpoint should be implemented by clients of the Grid API. ### Authentication The webhook includes a signature in the `X-Grid-Signature` header that allows you to verify that the webhook was sent by Grid. To verify the signature: 1. Get the Grid public key provided to you during integration 2. Decode the base64 signature from the header 3. Create a SHA-256 hash of the request body 4. Verify the signature using the public key and the hash If the signature verification succeeds, the webhook is authentic. If not, it should be rejected. ### Event types - `INTERNAL_ACCOUNT.BALANCE_UPDATED` — Fired when the balance of an internal account changes. The `data` payload contains the full internal account object. - `INTERNAL_ACCOUNT.STATUS_UPDATED` — Fired when the status of an internal account changes (e.g., `OPEN` → `FROZEN`). The `data` payload contains the full internal account object. - `INTERNAL_ACCOUNT.FUNDING_INSTRUCTIONS_UPDATED` — Fired when the account's `fundingPaymentInstructions` change: new funding coordinates became available (e.g., a SWIFT collection account finished provisioning) or previously served coordinates were withdrawn. The `data` payload contains the full internal account object, including the current `fundingPaymentInstructions`. # Invitation claimed webhook Source: https://agents-docs.lightspark.com/api-reference/webhooks/invitation-claimed-webhook /openapi.yaml webhook invitation-claimed Webhook that is called when an invitation is claimed by a customer. This endpoint should be implemented by platform clients of the Grid API. When a customer claims an invitation, this webhook is triggered to notify the platform that: 1. The invitation has been successfully claimed 2. The invitee UMA address is now associated with the invitation 3. The invitation status has changed from PENDING to CLAIMED This allows platforms to: - Track invitation usage and conversion rates - Trigger onboarding flows for new customers who joined via invitation - Apply referral bonuses or rewards to the inviter - Update their UI to reflect the claimed status ### Authentication The webhook includes a signature in the `X-Grid-Signature` header that allows you to verify that the webhook was sent by Grid. To verify the signature: 1. Get the Grid public key provided to you during integration 2. Decode the base64 signature from the header 3. Create a SHA-256 hash of the request body 4. Verify the signature using the public key and the hash If the signature verification succeeds, the webhook is authentic. If not, it should be rejected. # Outgoing payment status webhook Source: https://agents-docs.lightspark.com/api-reference/webhooks/outgoing-payment-status-webhook /openapi.yaml webhook outgoing-payment Webhook that is called when an outgoing payment's status changes. A side's `railDetails` carries the rail-level status of that leg; for example, a payout the receiving bank returns arrives as `OUTGOING_PAYMENT.FAILED` with `destination.railDetails.status` set to `RETURNED`, and the payout is refunded to its source. A returned payout stays `FAILED` even if the originating bank later dishonors the return: the refund has already been posted, so the funds are reconciled manually. The dishonor is still reported: `OUTGOING_PAYMENT.FAILED` is sent again with `railDetails.status` set to `RETURN_DISHONORED` and `dishonoredAt` set. This endpoint should be implemented by clients of the Grid API. ### Authentication The webhook includes a signature in the `X-Grid-Signature` header that allows you to verify that the webhook was sent by Grid. To verify the signature: 1. Get the Grid public key provided to you during integration 2. Decode the base64 signature from the header 3. Create a SHA-256 hash of the request body 4. Verify the signature using the public key and the hash If the signature verification succeeds, the webhook is authentic. If not, it should be rejected. This webhook is informational only and is sent when an outgoing payment completes successfully, fails, or is refunded. # Payout run finished webhook Source: https://agents-docs.lightspark.com/api-reference/webhooks/payout-run-finished-webhook /openapi.yaml webhook payout-run Fired when no row of a payout run is left to pay: `PAYOUT_RUN.COMPLETED`, `PAYOUT_RUN.PARTIALLY_FAILED` or `PAYOUT_RUN.FAILED`, with the run and its row counts. A cancelled run sends none. Each row's transaction sends its own `OUTGOING_PAYMENT.*` webhooks. This endpoint should be implemented by clients of the Grid API. ### Authentication The webhook includes a signature in the `X-Grid-Signature` header that allows you to verify that the webhook was sent by Grid. To verify the signature: 1. Get the Grid public key provided to you during integration 2. Decode the base64 signature from the header 3. Create a SHA-256 hash of the request body 4. Verify the signature using the public key and the hash If the signature verification succeeds, the webhook is authentic. If not, it should be rejected. # Settlement rule execution finished webhook Source: https://agents-docs.lightspark.com/api-reference/webhooks/settlement-rule-execution-finished-webhook /openapi.yaml webhook settlement-rule Fired once when a settlement rule's execution on an incoming payment finishes, with the execution: - `SETTLEMENT_RULE.EXECUTED`: its payout run finished `COMPLETED` or `PARTIALLY_FAILED`. `payoutRunId` names the run, whose own `PAYOUT_RUN.*` webhook carries the row counts. - `SETTLEMENT_RULE.FAILED`: its payout run finished `FAILED`, or was cancelled with `POST /payout-runs/{payoutRunId}/cancel`. The customer keeps what the run did not pay. A cancelled run still pays the shares whose payments had started; its rows say which. - `SETTLEMENT_RULE.SKIPPED`: nothing was paid; `skipReason` says why. Each share's transaction sends its own `OUTGOING_PAYMENT.*` webhooks. This endpoint should be implemented by clients of the Grid API. ### Authentication The webhook includes a signature in the `X-Grid-Signature` header that allows you to verify that the webhook was sent by Grid. To verify the signature: 1. Get the Grid public key provided to you during integration 2. Decode the base64 signature from the header 3. Create a SHA-256 hash of the request body 4. Verify the signature using the public key and the hash If the signature verification succeeds, the webhook is authentic. If not, it should be rejected. # Test webhook for integration verification Source: https://agents-docs.lightspark.com/api-reference/webhooks/test-webhook-for-integration-verification /openapi.yaml webhook test-webhook Webhook that is sent once to verify your webhook endpoint is correctly set up. This is sent when you configure or update your platform settings with a webhook URL. ### Authentication The webhook includes a signature in the `X-Grid-Signature` header that allows you to verify that the webhook was sent by the Grid API. To verify the signature: 1. Get the Grid public key provided to you during integration 2. Decode the base64 signature from the header 3. Create a SHA-256 hash of the request body 4. Verify the signature using the public key and the hash If the signature verification succeeds, the webhook is authentic. If not, it should be rejected. This webhook is purely for testing your endpoint integration and signature verification. # Verification status change Source: https://agents-docs.lightspark.com/api-reference/webhooks/verification-status-change /openapi.yaml webhook verification-update Webhook that is called when a customer's KYC/KYB verification status changes. This endpoint should be implemented by clients of the Grid API. ### Authentication The webhook includes a signature in the `X-Grid-Signature` header that allows you to verify that the webhook was sent by Grid. To verify the signature: 1. Get the Grid API public key provided to you during integration 2. Decode the base64 signature from the header 3. Create a SHA-256 hash of the request body 4. Verify the signature using the public key and the hash If the signature verification succeeds, the webhook is authentic. If not, it should be rejected. # Wallet operation completed or failed Source: https://agents-docs.lightspark.com/api-reference/webhooks/wallet-operation-completed-or-failed /openapi.yaml webhook wallet-operation Webhook that is called when an asynchronous embedded-wallet operation reaches a terminal state. Fires `WALLET_OPERATION.COMPLETED` on terminal success and `WALLET_OPERATION.FAILED` on terminal failure. The payload is self-contained — handle it from the fields below with no follow-up API call needed. The specific operation is carried in `data.operationType` (`auth_credential.create`, `auth_credential.delete`, `session.revoke`, `wallet.export`, or `customer.contact_update`), and the business resource it affected is carried in `data.resourceType` / `data.resourceId`: | `operationType` | `resourceType` | `resourceId` identifies | | --- | --- | --- | | `auth_credential.create` | `AUTH_METHOD` | the created credential — its id can't be known before this webhook, since the create request has nothing to echo | | `auth_credential.delete` | `AUTH_METHOD` | the deleted credential | | `session.revoke` | `SESSION` | the revoked session | | `wallet.export` | `INTERNAL_ACCOUNT` | the account whose wallet was exported | | `customer.contact_update` | `CUSTOMER` | the customer whose email or phone number changed | The webhook carries no sensitive result material — for `wallet.export`, the export bundle itself is never delivered here; retrieve it by resubmitting the original signed export request until it returns the result. ### Correlating this webhook - **`data.requestId`** is the primary correlation key. It is the same `Request-Id` value you supplied on the signed retry that produced this terminal result — echo it against the `Request-Id` you sent (and, if you polled through one or more `200 { status: "PROCESSING" }` responses, against the value you kept resending). - **`data.resourceId`** is the primary correlator for the *business resource* itself, distinct from `requestId`'s role of matching the request. For `auth_credential.create` this is the only way to learn the created credential's id — the request that created it had nothing to echo. - **`id`** (the top-level webhook envelope id) is for deduplication. Grid may redeliver a webhook after a transient failure; track `id` to avoid double-processing. - **`data.operationId`** is a Grid-internal identifier for the operation. It's useful when contacting support about a specific operation, but isn't intended as a correlation key. This endpoint should be implemented by clients of the Grid API. ### Authentication The webhook includes a signature in the `X-Grid-Signature` header that allows you to verify that the webhook was sent by Grid. To verify the signature: 1. Get the Grid public key provided to you during integration 2. Decode the base64 signature from the header 3. Create a SHA-256 hash of the request body 4. Verify the signature using the public key and the hash If the signature verification succeeds, the webhook is authentic. If not, it should be rejected. # Card design guidelines Source: https://agents-docs.lightspark.com/cards/card-design/guidelines Design the front of your card, choose the back's options, and submit artwork for physical cards and digital wallets Your physical card and its digital wallet art share one design: the front of the card. You design the front and choose a few options for the back. The chip, the magnetic stripe, the Visa marks, and the issuer statement are fixed. We prepare the production files and take the design through Visa's review. > [!NOTE] > > Physical cards are set up with your Lightspark contact as part of card > program onboarding. The Cards API issues virtual cards; see > [Issuing cards](/cards/card-management/issuing-cards). Start from the templates. Each is a 1536 × 969 SVG overlay with the guides on their own layers; it opens as editable vectors in Figma, Illustrator, Sketch, and Affinity. Keep it on a hidden or non-printing layer while you design. ## What you choose and what is fixed | Surface | You choose | Fixed | | - | - | - | | Front | Background color or artwork, your logo and where it sits | Chip position | | Back | Background color (usually the front's), personalization ink color and typeface, your support phone number and URL | Magnetic stripe, account block layout, contactless indicator, Visa DEBIT lockup in silver foil, "Issued by Lead Bank" statement | | Digital wallet art (Apple Pay, Google Pay) | The card image, derived from your front by default or supplied by you; your app icon and logo; two colors | Visa DEBIT lockup placement; the image must be recognizably the same card as the physical one | ## Card body Cards print on white card stock. Any color, gradient, or artwork prints edge to edge on both faces in CMYK, and the card edge is white. A black-core body is also available for black cards. White card stock, front. Any color or artwork, printed edge to edge. White card stock, back. Your color continues; stripe band to the top edge; silver foil lockup. Black core, front. Logo in white ink or one spot color. Black core, back. White personalization, silver foil lockup. * **White stock**: the standard body. Any color or artwork on the front; the same color continues on the back. The stripe band runs to the top edge of the back. Best for brand colors, gradients, and photographic or illustrated artwork. * **Black core**: black plastic through the card, so the edge is black. Personalization prints in white, and the logo prints in white or in one spot color (give us the Pantone number). Finishes: matte, gloss, or satin. Best for a black or one-color brand. Both bodies carry the Visa DEBIT lockup as the Premium Visa Brand Mark: silver hot-stamped foil on the back. Because the card carries the premium mark, Visa does not require the dove hologram, and the card has none. ## Canvas Work on a 1536 × 969 px artboard. It has the proportions of an ISO/IEC 7810 ID-1 card, and it is also the size Visa requires for digital wallet art, so one file serves both. | | Physical | On the artboard | | - | - | - | | Width | 85.6 mm | 1536 px | | Height | 53.98 mm | 969 px | | Corner radius | 3.18 mm | 57 px | | Scale | 1 mm | 17.94 px | Keep the artboard's corners square. The manufacturer cuts the corners; preview them at 57 px if you want to see the result. Run backgrounds past the card edge to give the cut bleed; the bleed amount depends on the manufacturer's press, and we confirm it with your proof. Anything that must stay legible (logos, text) stays inside a 54 px (3 mm) safe margin.
    px mm The [templates at the top of this page](#templates) carry every guide in this figure as a layer. ## Front The front carries your brand and the chip. Nothing else. * **Chip**: the module sits at (152, 324) and measures 236 × 164 px. It is embedded after printing, so nothing prints over it. Keep the zone from x 107 to 415 and y 279 to 533 clear of logos and text; background color and artwork may run through it. * **Logo**: the samples put it right of the chip, centered on the chip's mid-line (y 406), with its right edge 152 px from the card edge, 90 px tall. A larger mark, a centered mark, or a bleeding watermark all work if they stay out of the chip zone and legible parts stay inside the safe margin. * **Artwork**: photographs, gradients, and patterns are fine on white stock. Supply vector artwork where you can. Raster artwork must be at least 1536 × 969 px at card size; larger is better. Do not put on the front: * Cardholder name, card number, expiry date, or security code. These print on the back. * A drawn chip, hologram, or any imitation of a security feature. * Visa marks. We place the Visa DEBIT lockup on the back from Visa's supplied artwork. * Taglines, unless they are part of your trademarked logo. * URLs or phone numbers. The back carries your support line. Visa's rules also exclude, on either face: * The name or mark of another card network, or of a company you do not own. * Advertising or promotional language. * Reproductions of banknotes, coins, or another payment card. * Artwork, characters, or likenesses you do not hold the rights to. ## Back The back is a fixed layout. Your background color or artwork continues onto it, and you choose how the personalization prints. The blueprint carries the positions; the back template has them as guides.
      px mm | Element | What it is | Who sets it | | - | - | - | | Magnetic stripe | Standard black band. The band runs to the top edge of the card. | Fixed | | Account block | Three lines in 9 pt type: cardholder name, card number, then expiry and security code. Printed at personalization. | Layout fixed; ink and typeface yours | | Contactless indicator | EMVCo artwork, 5 mm tall. | Fixed | | Visa DEBIT lockup | Premium Visa Brand Mark in silver foil, with 3 mm clear space on every side kept free of text and logos. | Fixed | | Support line | Your support phone number and URL, in fine print. | Yours | | Issuer statement | "Issued by Lead Bank". | Fixed | Your choices on the back: * **Background**: the front's color or artwork, or a solid color from your palette. Keep the area under the lockup calm enough for silver foil to read. * **Personalization ink**: white or dark, whichever contrasts with the background. The black-core body prints white only. * **Typeface**: a sans serif, which Visa requires for a printed cardholder name, at 9 to 12 pt. The default is Suisse Intl Regular at 9 pt (57 px on the artboard; 1 pt = 6.33 px). The card number, expiry, and security code have no mandated face, and use the same one as the name. * **Support line**: the phone number and URL your cardholders should use. Cardholder names print as they appear on the cardholder's customer record. We shorten names that do not fit the line and confirm the result with you at proof. ## Color Cards are printed, so specify colors the way a printer uses them. * Give brand colors as Pantone numbers, or as CMYK values if your brand guide has them. Hex and RGB values are for the digital wallet art only. * CMYK reproduces a smaller range than a screen. Saturated screen colors, especially bright orange, lime green, cyan, magenta, and violet, print duller and darker. Look up your brand color's Pantone or CMYK equivalent before you commit to it; a color that looks right in Figma can disappoint on plastic. * On white stock, everything prints as a four-color CMYK process. For a saturated brand color that CMYK cannot reach, the black-core body prints your logo as a Pantone spot color instead, which reproduces the exact ink. * Metallic and fluorescent colors do not print in CMYK. A metallic look comes from foil (see [Beyond the standard card](#beyond-the-standard-card)). * Everything printed must contrast with what is behind it. This is a Visa requirement for the cardholder name and a legibility requirement for everything else. * The Visa DEBIT lockup is always silver foil. You do not choose its color. Print differs from screen in a few other ways that affect card artwork: * Set black as 100% K, not a CMYK build. Builds shift toward brown or blue on press. * Ink coverage under 10% in any channel can drop out. Avoid very light tints for anything that must be visible. * Avoid borders or frames that follow the card edge. The cut moves slightly from card to card, and a border makes that visible. * Dark solid colors with a matte finish show surface scratches sooner than lighter or glossier cards. * Keep type in your artwork at 6 pt or larger and lines at 0.08 mm (0.25 pt) or thicker. The digital proof and the printed samples are the color check. Screen previews, including the figures on this page, are not color-accurate. ## Digital wallet art When a cardholder adds the card to Apple Pay or Google Pay, the wallet shows a flat image of the card. By default we produce it from your front design. You can supply your own version instead, within the rules below; Visa and the wallets require it to be recognizably the same card as the physical one.
        px mm The image Visa and the wallets accept is a 1536 × 969 px PNG at 72 dpi, RGB, under 4 MB, with square corners (the wallet rounds them). The rules for what may appear on it: * No cardholder name, card number, expiry date, chip, hologram, or embossing cues. * No 3D or skeuomorphic effects that imitate a physical card. The wallet renders the rounding, stacking, shadows, and on iOS the tilt itself; a baked-in shadow or highlight fights that rendering. * The lower-left quadrant stays empty. The wallet draws the last four digits there. * The Visa DEBIT lockup sits in the lower right. We add it. If your logo sits right of the chip on the physical card, it lands in the upper right of the wallet art, which is allowed. A logo that depends on the chip for balance, or one placed in the lower left, needs an alternate placement for the wallet; that is the usual reason to supply your own version. The wallets also show your brand outside the card image, in notifications, transaction lists, and the card details screen. Visa Token Service, which provisions the card to Apple Pay and Google Pay, takes these assets with the card image: * An app icon, 100 × 100 px PNG. * Your logo, 1372 × 283 px PNG, on a transparent background. * An overlay text color for the last four digits, white or black, whichever reads on your art. * A card background color as a hex value. The wallet shows it while the image loads. ## Carrier and envelope Physical cards ship in a folded carrier letter with the card attached, inside an unbranded envelope. Card networks require that nothing on the envelope indicates a card is inside, so the envelope is not customized. The standard carrier is used for all programs; to discuss a custom carrier, contact your Lightspark contact. ## What to send Send the following to your Lightspark contact: * Brand colors as Pantone numbers or CMYK values for print, and as hex values for the digital wallet art. * The front design on a 1536 × 969 artboard, as the layered source file (Figma, Illustrator, or Sketch) or a PDF with vectors preserved. Keep your logo as a vector on its own layer, and include the overlay template on a hidden layer. * Your choices for the back: background color, personalization ink, typeface, support phone number and URL. * The body: white stock or black core, and the finish for black core. * For the digital wallets: the app icon, the logo PNG, the overlay text color, and the card background color listed under digital wallet art. ## Review and production **1. Design review** We check the design against the rules on this page and send back anything that needs to change. **2. Production files** We prepare the print files for both faces and the digital wallet art PNG, and place the Visa marks from Visa's supplied artwork. **3. Visa design approval** Visa reviews every card design before production. We submit and handle their feedback with you. **4. Proof and samples** The manufacturer returns a digital proof, then printed samples for you to approve. **5. Production** Approved designs go into production for your card program. Plan for several weeks from submission to printed samples, and longer for production stock. We give you a schedule at design review. ## Beyond the standard card The two bodies on this page are what most programs print. Metal, custom cores and edges, foil or spot-gloss logos, and other premium finishes are possible. Each raises the cost of every card, adds a minimum order, and adds months of lead time, and some change what the artwork can be. If you are considering one, talk to your Lightspark contact before you design. The same applies to a vertical orientation or a custom back layout. # Digital Wallet Tokenization Source: https://agents-docs.lightspark.com/cards/card-management/digital-wallet-tokenization Add cards to Apple Pay, Google Pay, and Samsung Pay, manually or from your app Cardholders can add a Grid-issued card to Apple Pay, Google Pay, or Samsung Pay in two ways. Manual entry works today for every card and needs nothing from you. In-app provisioning, an "Add to Wallet" button in your app, needs approval from each wallet provider plus one API call to Grid. ## Manual entry A cardholder opens their wallet app, chooses to add a card, and types in the card number, expiry, and CVV. The wallet asks the card network to tokenize the card, Grid approves it, and the card appears in the wallet. Use [`POST /cards/{id}/reveal`](/cards/card-management/issuing-cards) to show the cardholder the details they need to type. Nothing else is required: no wallet-provider approval, no SDK, no extra endpoint. > [!NOTE] > > Some tokenizations require a second factor. The wallet asks the > cardholder to confirm with a one-time code that Grid emails or texts on > your behalf. Brand those messages with `cardTokenization2faConfig` on > [`PATCH /config`](/api-reference/platform/update-platform-configuration). ## In-app provisioning: the "Add to Wallet" button In-app (push) provisioning lets the cardholder add the card from inside your app with one tap, without typing anything. Each wallet provider ships a library your app integrates to start that flow: | Wallet | Wallet SDK | What it hands your app | | - | - | - | | Apple Pay | PassKit, built into iOS (`PKAddPaymentPassViewController`) | `certificates`, `nonce`, `nonceSignature` | | Google Pay | Google's TapAndPay SDK for Android | `serverSessionId` and the wallet account id | | Samsung Pay | Samsung Pay SDK for Android | `walletUserId`, `deviceId` | When the cardholder taps the button, the wallet SDK produces those values, which identify the device and the wallet session. Your app sends them to Grid, an encrypted payload is returned, and the wallet SDK uses that payload to finish adding the card. The payload is encrypted to the wallet provider's keys, so neither your app nor your servers ever see card data. ```mermaid theme={null} sequenceDiagram participant W as Wallet SDK (on device) participant A as Your app participant G as Grid A->>W: Cardholder taps "Add to Wallet" W-->>A: Wallet-specific values (nonce, session id, …) A->>G: POST /cards/{id}/tokenize { wallet, … } G-->>A: 200 { encrypted provisioning payload } A->>W: Hand payload to the SDK W-->>A: Card added ``` Only the "Add to Wallet" button requires the setup below. Each wallet provider must approve your app before its SDK will provision cards, and the approvals are the slowest step, start them early. ### Apple Pay 1. Your Apple developer account owner submits Apple's [In-App Provisioning request form](https://developer.apple.com/contact/passkit/) with your company name, app name, Adam ID from App Store Connect, sponsor bank, and card program details. Name Lightspark as the issuer processor. 2. Apple approves and sends instructions for the `com.apple.developer.payment-pass-provisioning` entitlement and the pass metadata. 3. Send your Lightspark contact the app's Application Identifiers, Adam ID, and launch URL. We register them with the card processor so the wallet can hand off to your app. 4. In the app, open Apple's add-to-Wallet screen (`PKAddPaymentPassViewController`). When the cardholder continues, Apple hands your app three values that identify this device's Wallet and this session: `certificates`, `nonce`, and `nonceSignature`. Send them to Grid with `POST /cards/{id}/tokenize`. ### Google Pay 1. Request access to Google's [Push Provisioning API](https://developers.google.com/pay/issuers/apis/push-provisioning/android) through the [Push Provisioning API Access Request](https://support.google.com/faqs/contact/pp_api_allowlist) form. 2. Submit your "Add to Google Wallet" flow through Google's [Push Provisioning API UX Review Request](https://support.google.com/faqs/contact/pp_api_ux) form. 3. Google approves and allowlists your app. Integrate the TapAndPay SDK. 4. In the app, start the add-to-Wallet flow through the TapAndPay SDK using Google's current flow, Unified Push Provisioning (UPP). Google Wallet runs the screens itself and then calls your app back asking for the card's payment credentials, handing you a `serverSessionId` that identifies this session. Send it, with the wallet account id, to Grid with `POST /cards/{id}/tokenize`, and return Grid's payload from that callback. > [!WARNING] > > Grid supports UPP only. Google's older push provisioning flow, where the > app fetches the payload before starting the SDK, is being retired at the > end of 2026 and will not work with Grid. ### Samsung Pay 1. Create a Samsung Pay developer account and request access to the Samsung Pay developer portal. 2. Create a push provisioning service and register your app for card enrollment. 3. Submit your production app for Samsung's review. Once released, integrate the Samsung Pay SDK and use Samsung's official "Add to Samsung Wallet" button assets. 4. In the app, start the add-to-Wallet flow through the Samsung Pay SDK. It hands your app two values that identify the cardholder's Samsung Wallet and this device: `walletUserId` and `deviceId`. Send them to Grid with `POST /cards/{id}/tokenize`, and pass Grid's payload to the SDK's `addCard` call. ## The API call `POST /cards/{id}/tokenize` takes the wallet name plus the values the SDK produced and returns the payload the SDK needs. Hand the response straight back to the SDK. > [!NOTE] > > This call is authenticated with your platform credentials like every > other Grid call, so make it from your server and relay the result to > the app rather than embedding the credentials in the app. The request and response bodies are discriminated by `wallet`: each wallet has its own request shape and its own response shape, so a request carrying a field from another wallet's shape is rejected. | `wallet` | Request shape | Response shape | | - | - | - | | `APPLE_PAY` | `certificate`, `nonce`, `nonceSignature` (all required) | `applePay` object | | `GOOGLE_PAY` | `walletAccountId`, `serverSessionId` (required); `deviceId`, `opaquePaymentCardRequested` (optional) | `provisioningPayload`, plus `googleAccountPayload` when requested | | `SAMSUNG_PAY` | `walletAccountId`, `deviceId` (both required) | `provisioningPayload` | Pass every value exactly as the SDK gave it. `certificate` is the leaf certificate (the first element of Apple's `certificates` array), base64-encoded in PEM form; `nonce` and `nonceSignature` are base64. > [tab] Apple Pay > > ```bash theme={null} > curl -X POST "$GRID_BASE_URL/cards/Card:019542f5-b3e7-1d02-0000-000000000010/tokenize" \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -H "Content-Type: application/json" \ > -d '{ > "wallet": "APPLE_PAY", > "certificate": "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUM=", > "nonce": "MTIzNDU2Nzg=", > "nonceSignature": "c2lnbmF0dXJl" > }' > ``` > > ```json theme={null} > { > "wallet": "APPLE_PAY", > "applePay": { > "activationData": "YWN0aXZhdGlvbi1kYXRh", > "encryptedPassData": "ZW5jcnlwdGVkLXBhc3MtZGF0YQ==", > "ephemeralPublicKey": "ZXBoZW1lcmFsLXB1YmxpYy1rZXk=" > } > } > ``` > > Build a `PKAddPaymentPassRequest` from the three `applePay` fields > and pass it to the delegate's completion handler. > [tab] Google Pay > > ```bash theme={null} > curl -X POST "$GRID_BASE_URL/cards/Card:019542f5-b3e7-1d02-0000-000000000010/tokenize" \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -H "Content-Type: application/json" \ > -d '{ > "wallet": "GOOGLE_PAY", > "walletAccountId": "wallet-account-8f14e45f", > "serverSessionId": "8c3b8a7e-2a5d-4b7f-9c1e-6d2f0a1b3c4d", > "opaquePaymentCardRequested": false > }' > ``` > > ```json theme={null} > { > "wallet": "GOOGLE_PAY", > "provisioningPayload": "eyJjYXJkIjoiLi4uIn0=" > } > ``` > > Return `provisioningPayload` from the TapAndPay payment-credentials > callback. When Google's callback asked for an opaque payment card, > set `opaquePaymentCardRequested: true` and also return the > `googleAccountPayload` the response then carries. > [tab] Samsung Pay > > ```bash theme={null} > curl -X POST "$GRID_BASE_URL/cards/Card:019542f5-b3e7-1d02-0000-000000000010/tokenize" \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -H "Content-Type: application/json" \ > -d '{ > "wallet": "SAMSUNG_PAY", > "walletAccountId": "wallet-account-8f14e45f", > "deviceId": "device-3c59dc04" > }' > ``` > > ```json theme={null} > { > "wallet": "SAMSUNG_PAY", > "provisioningPayload": "eyJjYXJkIjoiLi4uIn0=" > } > ``` > > Pass `provisioningPayload` to the Samsung Pay SDK's `addCard` call. ## Web push provisioning: "Add to Wallet" on your website Apple and Google also offer an "Add to Wallet" button for websites. Their script runs the flow in the browser and asks your page for the card; your backend calls [`POST /cards/{id}/web-tokenize`](/api-reference/cards/add-a-card-to-a-digital-wallet-from-a-website) and hands the response to the script. | Wallet | Script | What your page sends Grid | What Grid returns | | - | - | - | - | | Apple Pay | Apple's `initAddToAppleWallet`, from its `jwsResolver` | `wallet` only | `applePay.jws` and `applePay.state` | | Google Pay | Google's `googlepay.openAppWindow`, from its `onSessionCreated` | `serverSessionId`, `publicDeviceId`, `publicWalletId` | `googlePay.tspOpc` and, when Google asks for it, `googlePay.googleOpc`, for `googlepay.pushPaymentCredentials` | Authenticate the cardholder before the button starts the flow. Web push provisioning is enabled per website by the card issuer and needs each wallet provider's review of your site, so contact your Lightspark representative before building it. Until it is enabled, the call returns `409 CONFLICT` and manual entry still works. ## Errors | Status | Code | When | | - | - | - | | `400` | `INVALID_INPUT` | The body does not match the request shape for its `wallet` (a required field is missing, or a field from another wallet's shape was supplied), or the wallet provider rejected the values (for example, an expired Apple nonce). Restart the add-to-wallet flow in the app to get fresh values. | | `401` | `UNAUTHORIZED` | Your platform credentials were rejected, or your production platform is not yet enabled for card issuing. Contact your Lightspark representative for the latter. | | `403` | `FORBIDDEN` | The credential lacks `uma:manage`, or the session has no attributable actor to audit the request against (for example, an impersonated dashboard session). Retry with a platform API credential. | | `404` | `NOT_FOUND` | The card does not exist on your platform. | | `409` | `CARD_NOT_MUTABLE` | The card is `CLOSED`. | | `409` | `CONFLICT` | The card is not `ACTIVE`, or its `cardCapabilities.supportsDigitalWalletTokenization` is false. Unfreeze the card first; the capability itself is fixed for the card's lifetime. | | `500` | `INTERNAL_ERROR` | The card issuer could not be reached or returned an unusable response. Retry; if it persists, [contact our team](https://www.lightspark.com/contact). | | `501` | `NOT_IMPLEMENTED` | Cards are not enabled in this environment. | Every call is audit-logged with the requesting actor and the target wallet. The payload itself is never logged. ## Sandbox Sandbox cards accept `POST /cards/{id}/tokenize` and return a well-formed payload, but the wallet SDKs on a real device will not accept sandbox cards. Test the request shape and error handling against Sandbox, and the end-to-end device flow against a Production card once your wallet approvals are in place. # Embedded Wallet Funding Source: https://agents-docs.lightspark.com/cards/card-management/embedded-wallet-funding Fund a card from a customer's Embedded Wallet by delegating a signing key to Grid A card can be funded from a customer's Embedded Wallet: an `EMBEDDED_WALLET` internal account that holds USDB and that only the customer can sign for. Grid cannot debit that account the way it debits a custodial funding source, and a card authorization has to be answered in seconds, so the cardholder cannot sign each purchase. Instead the cardholder authorizes a **delegated key** once. Grid custodies the key and uses it to sign the pull from the wallet at every authorization. This page covers issuing the card, creating the delegated key, what happens when an authorization arrives, and what fails when the card has no active key. ## Prerequisites * Cards are enabled for your platform, and your Lightspark contact has enabled Embedded Wallet card funding for it. * The cardholder is a `Customer` with `kycStatus: APPROVED` and has an `EMBEDDED_WALLET` internal account. To find it, list the customer's internal accounts with `GET /internal-accounts?customerId=...` and pick the account with `type: "EMBEDDED_WALLET"`. * The wallet is provisioned. Grid provisions the wallet's signer the first time a credential on the account is verified, so the cardholder must have completed at least one credential verification. See [Authentication](/global-accounts/authentication). * Your client can hold a session signing key and stamp a `payloadToSign`. Creating the delegated key needs two stamps from the cardholder's active session. See [Client keys](/global-accounts/client-keys). * The wallet holds USDB on Spark. Each authorization spends the wallet's on-chain balance at authorization time. ## How the pieces fit 1. You issue the card with the wallet account as its `fundingSource`. 2. The cardholder authorizes a delegated key for that card and account through a three-leg signed-retry flow. Grid generates the keypair, registers it as a signer on the wallet, and keeps the private key. 3. When an authorization arrives, Grid runs its usual decisioning, then pays the hold from the wallet: it builds a USDB transfer for the authorized amount, signs it with the delegated key, and broadcasts it on Spark. 4. Clearings capture the held USDB. Reversals and expiries return it to the wallet. Neither needs the cardholder. ## Issue the card Issue the card the same way as any other, with the Embedded Wallet account as the funding source: ```bash theme={null} curl -X POST "$GRID_BASE_URL/cards" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "form": "VIRTUAL", "fundingSource": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" }' ``` The card's `currency` is `USD`. The wallet holds USDB, which Grid converts 1:1, so a \$12.50 authorization pulls 12.50 USDB. The card comes back in `status: "PROCESSING"` and moves to `PENDING_AUTH` through the `CARD.STATUS_CHANGE` webhook as described in [Issuing cards](/cards/card-management/issuing-cards). Complete the next section to authorize funding and activate the card before you show it to the cardholder. Manage transaction amount, daily amount, and daily count caps through [Velocity limits](/cards/card-management/velocity-limits). Omit `cardId` for platform defaults or supply the issued card's id for a card-specific limit. USD- and USDB-funded USD cards both use USD cents for amount caps. ## Create the delegated key `POST /auth/delegated-keys` is a three-leg flow. Every leg sends the same body; the second and third legs add a stamp from the cardholder's session signing key. Your backend relays each `payloadToSign` to the client and the resulting stamp back to Grid, the same shape as [passkey registration](/global-accounts/authentication#passkey-registration). ```mermaid theme={null} sequenceDiagram participant C as Client (holds session key) participant IB as Integrator backend participant G as Grid IB->>G: POST /auth/delegated-keys { cardId, internalAccountId, nickname } G-->>IB: 202 { payloadToSign, requestId, expiresAt } IB-->>C: payloadToSign, requestId C->>C: stamp(payloadToSign, sessionPrivateKey) C-->>IB: stamp IB->>G: Same POST
        Grid-Wallet-Signature: stamp
        Request-Id: requestId G-->>IB: 202 { payloadToSign, requestId, expiresAt } IB-->>C: payloadToSign, requestId C->>C: stamp(payloadToSign, sessionPrivateKey) C-->>IB: stamp IB->>G: Same POST
        Grid-Wallet-Signature: stamp
        Request-Id: requestId G-->>IB: 201 DelegatedKey { status: ACTIVE } ``` ### Leg 1: request the challenge Send the request with no signature headers. `cardId` and `internalAccountId` identify the card funding source; the account must be the card's current `fundingSource`. ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/delegated-keys" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "cardId": "Card:019542f5-b3e7-1d02-0000-000000000010", "internalAccountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "nickname": "Card payments key" }' ``` Grid generates the keypair and returns `202`: ```json theme={null} { "payloadToSign": "{\"type\":\"ACTIVITY_TYPE_CREATE_USERS_V3\",...}", "requestId": "Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21", "expiresAt": "2026-05-08T14:15:00Z" } ``` Each challenge expires five minutes after it is issued. If the cardholder takes longer, start again from leg 1. ### Leg 2: authorize the signer The client stamps `payloadToSign` byte for byte with the session signing key. Retry the same request with the stamp in `Grid-Wallet-Signature` and the `requestId` in `Request-Id`: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/delegated-keys" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: $STAMP" \ -H "Request-Id: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ -d '{ "cardId": "Card:019542f5-b3e7-1d02-0000-000000000010", "internalAccountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "nickname": "Card payments key" }' ``` Grid registers the key as a signer on the wallet and returns a second `202` with a new `payloadToSign` and `requestId`. The key now exists in `PENDING` status: the signer is registered but holds no signing policy yet, so it cannot sign. ### Leg 3: grant the signing policy The client stamps the new `payloadToSign`. Retry once more with the new `Request-Id`. The response is `201` with the key in `ACTIVE` status: ```json theme={null} { "id": "DelegatedKey:019542f5-b3e7-1d02-0000-000000000021", "cardId": "Card:019542f5-b3e7-1d02-0000-000000000010", "fundingSourceId": "CardFundingSource:019542f5-b3e7-1d02-0000-000000000011", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "publicKey": "02a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90", "nickname": "Card payments key", "status": "ACTIVE", "createdAt": "2026-05-08T14:12:01Z", "updatedAt": "2026-05-08T14:12:42Z" } ``` The private key never leaves Grid. `publicKey` identifies the credential and is not needed for anything else. ### Rules * A card funding source has at most one `ACTIVE` key. A second create returns `409 CONFLICT` until you revoke the first. * A flow abandoned after leg 2 leaves a `PENDING` key. It cannot sign and does not block creating another key. * The key does not expire. It stays `ACTIVE` until you revoke it. * The key is bound to the pair of card and funding account. If you replace the card's `fundingSource` with a different account, create a new key for the new pairing and revoke the old one. ### Errors | Status | Code | What it means | | - | - | - | | 400 | `INVALID_INPUT` | `internalAccountId` is not an `EMBEDDED_WALLET` account, or the wallet is not provisioned yet. Verify a credential on the account first. | | 401 | `UNAUTHORIZED` | The stamp is missing on a retry, does not verify against an active session on the account, or `Request-Id` does not match an unexpired challenge. | | 404 | `NOT_FOUND` | The card or account is not on your platform, or the account is not the card's current funding source. | | 409 | `CONFLICT` | The funding source already has an `ACTIVE` delegated key. Revoke it with `DELETE /auth/delegated-keys/{id}` first. | ## What happens at authorization When the network asks Grid to authorize a purchase on the card: 1. Grid applies the same checks as for any card: the card is `ACTIVE`, the amount fits the effective card velocity limits, and program rules allow the merchant. 2. Grid builds a USDB transfer from the wallet for the authorized amount, signs it with the delegated key, and broadcasts it on Spark. The wallet's balance drops by the authorized amount now, not at clearing. 3. Grid approves the authorization once the broadcast is accepted and sends `CARD_TRANSACTION.AUTHORIZED`. `accountId` on the transaction is the wallet account. Later events on the transaction: * **Clearing.** Grid captures the held USDB. The wallet is not touched again for the authorized amount. If the merchant clears for more than the authorization, Grid pulls the difference from the wallet the same way it pulled the hold, signed with the delegated key. * **Reversal or expiry.** Grid returns the held USDB to the wallet on Spark. The return is a separate on-chain transfer into the wallet and does not use the delegated key. Two behaviors differ from a custodial funding source: * **The balance check is real and synchronous.** Grid selects the wallet's USDB outputs before it signs. If the wallet cannot cover the amount, the authorization is declined, and no hold is placed. * **Approval waits on the broadcast.** The authorization is approved only after the signed transfer has been accepted by the Spark network. ## What fails without an active key If an otherwise enabled card's funding source has no `ACTIVE` key (never created, still `PENDING`, or revoked), the provisioned card is `PENDING_AUTH` with `statusReason: "DELEGATION_REQUIRED"`. Grid declines new synchronous authorizations before pulling funds from the wallet. The card transaction is recorded with `status: "DECLINED"` and `cardDeclinedReason: "CARD_NOT_ACTIVE"`, and a `CARD_TRANSACTION.DECLINED` webhook fires. Check the card's `statusReason` and its funding source's delegated keys when you investigate a pending wallet-funded card: ```bash theme={null} curl -X GET "$GRID_BASE_URL/auth/delegated-keys?fundingSourceId=CardFundingSource:019542f5-b3e7-1d02-0000-000000000011" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` An empty `data` array, or one with no `ACTIVE` entry, means the cardholder must complete the three-leg flow, including both signed retries. Once the new key is `ACTIVE`, the pending card becomes `ACTIVE` automatically and emits `CARD.STATUS_CHANGE`. Revoking the required key moves an active card to `PENDING_AUTH` and emits `CARD.STATUS_CHANGE`. Processing, frozen, and closed cards retain their status. To stop a card from spending without touching the key, freeze it with `PATCH /cards/{id}` and `status: "FROZEN"`. ## Manage the key List the keys for an account or a funding source. At least one filter is required. `PENDING` and `REVOKED` keys are included: ```bash theme={null} curl -X GET "$GRID_BASE_URL/auth/delegated-keys?accountId=InternalAccount:019542f5-b3e7-1d02-0000-000000000002" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Revoke a key. No signature is needed; Grid uses the custodied key to remove its own signer from the wallet: ```bash theme={null} curl -X DELETE "$GRID_BASE_URL/auth/delegated-keys/DelegatedKey:019542f5-b3e7-1d02-0000-000000000021" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` The response is `204`. Revoking a key that is not `ACTIVE` returns `400 INVALID_INPUT`. Revocation is permanent. To let the card spend again, run the three-leg flow to create a new key. Revoke the key when the card is closed or when the cardholder asks to withdraw Grid's authority over the wallet. Closing the card does not revoke the key. ## Testing in Sandbox The flow is the same in Sandbox. Sandbox wallets run on Spark regtest, and a sandbox authorization pulls regtest USDB from the wallet into a sandbox treasury. * Sign the delegated-key legs with a real API-key stamp over `payloadToSign`, signed with the session signing key. It works in Sandbox exactly as in production. See [Sign a `payloadToSign`](/global-accounts/client-keys#2-sign-a-payloadtosign). * `POST /sandbox/internal-accounts/{id}/fund` credits the account's ledger balance only. A card hold spends the wallet's balance on Spark, so fund the wallet with a quote whose destination is the wallet account, as described in the [Global Accounts walkthrough](/global-accounts/implementation-overview), before you simulate an authorization. * `POST /sandbox/cards/{id}/simulate/authorization` with any non-magic descriptor drives the pull. Run it once with the key in place and once after revoking the key to see both outcomes. See [Sandbox testing](/cards/platform-tools/sandbox-testing) for the simulate endpoints and the descriptor suffixes. # Freezing & Closing Cards Source: https://agents-docs.lightspark.com/cards/card-management/freezing-and-closing Freeze, unfreeze, and close cards via the signed-retry pattern Freeze, close, and other card updates use a single authenticated `PATCH /cards/{id}` request and return the updated card with `200 OK`. `PATCH /cards/{id}` covers status, funding source, and other card settings. See [Funding sources](/cards/card-management/funding-sources) for the funding-source-only flow and [Velocity limits](/cards/card-management/velocity-limits) for amount and count caps. ## Valid status transitions | From | To | Endpoint | | - | - | - | | `ACTIVE` | `FROZEN` | `PATCH /cards/{id}` body `{ "status": "FROZEN" }` | | `FROZEN` | `ACTIVE` | `PATCH /cards/{id}` body `{ "status": "ACTIVE" }` | | `ACTIVE` or `FROZEN` | `CLOSED` | `PATCH /cards/{id}` body `{ "status": "CLOSED" }` | Any other transition returns `409 INVALID_STATE_TRANSITION`. In particular, you cannot un-freeze a `CLOSED` card — close is terminal. `{ "status": "ACTIVE" }` also returns `409 INVALID_STATE_TRANSITION` when: * the customer is not `ACTIVE`; * Grid froze the card for review; or * the card was frozen with substatus `COMPROMISED`, including by you. Contact Grid support to have a card reviewed. A compromised card cannot be unfrozen: close it and issue a new one. Once a suspended customer is `ACTIVE` again, you can unfreeze cards that were frozen only because of the suspension. You can also combine a status change with a funding source replacement in one PATCH — just include both fields in the body. ```bash theme={null} curl -X PATCH "$GRID_BASE_URL/cards/Card:019542f5-b3e7-1d02-0000-000000000010" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "status": "FROZEN" }' ``` The response is `200 OK` with the updated `Card` and a `CARD.STATUS_CHANGE` webhook. ## What freeze does Setting a card to `FROZEN`: * Causes Authorization Decisioning to decline new auths with `cardDeclinedReason: CARD_NOT_ACTIVE`. * Does **not** pause the lifecycle of authorizations that already passed. Pulls, clearings, and refunds against existing transactions continue to reconcile normally. * Emits `CARD.STATUS_CHANGE` with `status: "FROZEN"`. Unfreeze (`status: "ACTIVE"`) reverses this — new auths flow normally again. ## What close does Closing a card is done with the same `PATCH /cards/{id}` endpoint by setting `status: "CLOSED"`. The operation is permanent: * Card status transitions to `CLOSED`, `statusReason: "CLOSED_BY_PLATFORM"`. * All pending authorizations reconcile to a terminal state via the existing reconcile primitive. * Funding-source bindings are detached. Refunds already in flight continue to complete because Lightspark holds the card-reserve keys. * Inbound clearings received after close follow the standard force-post / late-presentment path — Lightspark absorbs the loss if a post-hoc pull on the now-unbound source fails. * `CARD.STATUS_CHANGE` fires with `status: "CLOSED"`. ```bash theme={null} curl -X PATCH "$GRID_BASE_URL/cards/Card:019542f5-b3e7-1d02-0000-000000000010" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "status": "CLOSED" }' ``` `fundingSource` cannot be supplied alongside `status: CLOSED`. `409 CARD_ALREADY_CLOSED` is returned if the card is already in the terminal `CLOSED` state. ## Managing velocity limits Use [Velocity limits](/cards/card-management/velocity-limits) to configure per-authorization, daily, rolling, and other calendar-window amount and count caps. `PATCH /cards/{id}` changes the card's status and other card settings; velocity limits are managed through `/card-velocity-limits`. ## Sandbox behavior In Sandbox the status changes are instant — no issuer round-trip is simulated. The request and response shape is the same as production. # Funding Sources Source: https://agents-docs.lightspark.com/cards/card-management/funding-sources Bind and update internal accounts as card funding sources A card's `fundingSource` is the internal account that Authorization Decisioning pulls from when an authorization arrives. Every card is bound to one funding source. ## At issue time Supply `fundingSource` when you create the card with `POST /cards`: ```bash theme={null} curl -X POST "$GRID_BASE_URL/cards" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "form": "VIRTUAL", "fundingSource": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" }' ``` The internal account must: * Belong to the customer. * Be denominated in a card-eligible currency. The card program fixes the card's `currency` at issuance. USDB-funded cards transact in USD, with funding converted at 1 USDB = 1 USD. Their spending limits are in USD cents. If the account does not qualify, Grid rejects the request with `400 INVALID_INPUT`. If the funding source is an `EMBEDDED_WALLET` account, the cardholder must also authorize a delegated signing key before the card can fund a transaction. See [Embedded Wallet funding](/cards/card-management/embedded-wallet-funding). ## Replace the funding source Supply a different `fundingSource` with `PATCH /cards/{id}` to replace the account that funds the card: ```bash theme={null} curl -X PATCH "$GRID_BASE_URL/cards/Card:019542f5-b3e7-1d02-0000-000000000010" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "fundingSource": "InternalAccount:019542f5-b3e7-1d02-0000-000000000003" }' ``` The replacement account must belong to the customer and use a currency supported by the card's program, including USDB for USD cards. The response returns the updated `Card` resource. Changing `fundingSource` does not change the card's currency or spending-limit units, and does not fire a webhook. You cannot supply `fundingSource` alongside `status: CLOSED`. To stop a card from spending, set `status: FROZEN` instead. ### Errors | Status | Code | What it means | | - | - | - | | 400 | `INVALID_INPUT` | The account does not belong to the customer, or its currency is not supported by the card program. Also returned when `fundingSource` is supplied alongside `status: CLOSED`, or another request field is invalid. | | 409 | `CARD_NOT_MUTABLE` | The card is `CLOSED`. | ## Stop a card from spending Freeze the card without changing its funding source: ```bash theme={null} curl -X PATCH "$GRID_BASE_URL/cards/Card:019542f5-b3e7-1d02-0000-000000000010" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "status": "FROZEN" }' ``` To permanently retire a card, close it with `PATCH /cards/{id}` and `status: "CLOSED"`. ## Funding authorization and readiness Replacing USD funding with a USDB Embedded Wallet without an active delegated key moves an active card to `PENDING_AUTH`. Complete all signed retries of `POST /auth/delegated-keys` to restore `ACTIVE`. A key for another card or funding source does not authorize this binding. Frozen and closed cards retain their status. # Issuing Cards Source: https://agents-docs.lightspark.com/cards/card-management/issuing-cards Create a virtual card and observe its lifecycle A card is created with a single `POST /cards` request and progresses through a fixed lifecycle. This page covers the request shape, what happens after issuance, and the errors you should handle. ## Request shape ```bash theme={null} curl -X POST "$GRID_BASE_URL/cards" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "form": "VIRTUAL", "fundingSource": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" }' ``` | Field | Required | Notes | | - | - | - | | `customerId` | Yes | The `Customer` that owns the card. Must be `kycStatus: APPROVED`. | | `platformCardId` | No | Ignored if supplied. The value on the returned `Card` is generated by the server. | | `form` | Yes | `VIRTUAL` in v1. `PHYSICAL` will be added later. | | `fundingSource` | Yes | The `InternalAccount` that funds the card. Must belong to the customer and be denominated in a card-eligible currency. | The card program fixes the card's `currency` at issuance and returns it on the `Card` resource. USDB-funded cards have `currency: "USD"`, with funding converted at 1 USDB = 1 USD. Spending limits use the smallest unit of the card's currency: for a USDB-funded card, `50000` means \$500.00 in USD cents. Changing the funding source does not change the card's currency or spending-limit units. Configure platform defaults before issuing cards, then add card-specific limits with the returned card id through [Velocity limits](/cards/card-management/velocity-limits). The card creation request does not configure velocity limits. ## The lifecycle ```text theme={null} USD: PROCESSING ──► ACTIVE ◄──► FROZEN │ │ └───────────┴──► CLOSED USDB: PROCESSING ──► PENDING_AUTH ◄──► ACTIVE ◄──► FROZEN │ │ │ └─────────────────┴───────────┴──► CLOSED ``` | State | When you see it | | - | - | | `PROCESSING` | Returned synchronously from `POST /cards`. The card cannot transact yet. | | `PENDING_AUTH` | The issuer has provisioned the card, but its USDB Embedded Wallet funding source has no active delegated key. Complete delegated-key setup. | | `ACTIVE` | Issuer provisioned the card. Reached via `CARD.STATUS_CHANGE` webhook. | | `FROZEN` | You called `PATCH /cards/{id}` with `status: "FROZEN"`. | | `CLOSED` | You called `PATCH /cards/{id}` with `status: "CLOSED"`. Terminal. | `PENDING_KYC` is also a valid status but you should not see it in v1 — issuance is gated on KYC up front. ## After issuance `POST /cards` returns immediately with `status: "PROCESSING"`. The issuer provisions the card asynchronously; on success a `CARD.STATUS_CHANGE` webhook fires with the activated `Card` resource including the populated `last4`, `expMonth`, and `expYear`. For USDB Embedded Wallet funding without an active delegated key, the provisioning webhook reports `PENDING_AUTH` with `statusReason: "DELEGATION_REQUIRED"`. Complete all three calls to `POST /auth/delegated-keys`, including both signed retries. When the final retry returns an `ACTIVE` delegated key, the pending card becomes `ACTIVE` automatically and emits `CARD.STATUS_CHANGE`. USD funding does not require delegation. If delegation completes before provisioning, the card can go directly from `PROCESSING` to `ACTIVE`. Replacing USD funding with USDB lacking an active key can move an active card back to `PENDING_AUTH`. Switching back to USD, or completing delegation, restores readiness. Revoking the required key also moves an active card to `PENDING_AUTH`. Frozen and closed cards retain their status. ## Revealing the PAN To show the cardholder their full PAN, CVV, and expiry, request a reveal right before rendering: ```bash theme={null} curl -X POST "$GRID_BASE_URL/cards/Card:019542f5-b3e7-1d02-0000-000000000010/reveal" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ```json theme={null} { "panEmbedUrl": "https://embed.lithic.com/iframe/...?t=...", "expiresAt": "2026-05-08T14:16:00Z" } ``` `panEmbedUrl` is a signed URL for the card processor's iframe that renders the full credentials directly to the cardholder. The full PAN and CVV never cross your servers or Grid's. > [!NOTE] > > For cards in programs where Grid makes authorization decisions, > `POST /cards/{id}/reveal` is the **only** way to obtain a reveal URL; > cards in programs where the card issuer makes authorization decisions use > the card issuer's challenge-based hosted reveal flow instead. The `Card` > resource never carries one (not in responses, not in webhook > payloads). The URL expires at `expiresAt` (within minutes), so request a > fresh reveal each time the cardholder asks for their details, > immediately before rendering the iframe. Never store, cache, or log the > URL. Every reveal is audit-logged. ### Styling the reveal By default the iframe renders with Grid's own styling. To carry your branding instead, host a stylesheet and point your platform config at it: ```bash theme={null} curl -X PATCH "$GRID_BASE_URL/config" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{"cardConfigs": {"panRevealCssUrl": "https://acme.com/card-reveal.css"}}' ``` Every reveal minted afterwards links your stylesheet. Send `null` to go back to the default. To style one reveal differently — to match the cardholder's light or dark theme, say — send `cssUrl` in the reveal request instead. It overrides the platform setting for that call only: ```bash theme={null} curl -X POST "$GRID_BASE_URL/cards/Card:019542f5-b3e7-1d02-0000-000000000010/reveal" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{"cssUrl": "https://acme.com/card-reveal-dark.css"}' ``` Style these ids and classes; the rest of the page structure is not a contract and can change: | Selector | What it is | | - | - | | `#card` | The wrapper around the whole card | | `#pan` | The full card number | | `.pan-separator` | The gap between PAN digit groups | | `#expiry` | The expiry date | | `#cvv` | The security code | ```css theme={null} #card { background: #1a1a1a; border-radius: 16px; padding: 24px; font-family: "Acme Sans", sans-serif; } #pan { font-size: 21px; letter-spacing: 0.04em; color: #ffffff; } ``` > [!WARNING] > > Both settings load a stylesheet into the page that renders the live PAN, > expiry, and CVV, so host it somewhere you would trust with card details. It > must be reachable over HTTPS and must not carry credentials in the URL. Grid > never fetches it — the cardholder's browser does — so a stylesheet that fails > to load produces an unstyled card rather than a server error. Reveal a > sandbox card to check yours before you ship it. ## Errors to handle | Status | Code | What it means | | - | - | - | | 400 | `CARDHOLDER_KYC_NOT_APPROVED` | Cardholder is not `kycStatus: APPROVED`. Drive KYC to completion before retrying. | | 400 | `INVALID_INPUT` | The supplied internal account doesn't belong to the cardholder or isn't denominated in a card-eligible currency, or another field failed validation. | | 409 | `CONFLICT` | The card belongs to a program where the card issuer makes authorization decisions. Use the card issuer's challenge-based hosted reveal flow instead; retrying this endpoint will not help. | ## Changing the funding source later The bound funding source can be replaced after issuance via `PATCH /cards/{id}` with a new `fundingSource`. See [Funding sources](/cards/card-management/funding-sources) for the rules and request example. ## Listing cards ```bash theme={null} curl -X GET "$GRID_BASE_URL/cards?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001&limit=20" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Filter by `customerId`, `platformCardId`, or `status`. The response is paginated using the standard cursor shape used by other Grid list endpoints. # Card PINs Source: https://agents-docs.lightspark.com/cards/card-management/pins Let cardholders set, change, check, and unblock online PINs on cards. Cards have no PIN set by default. Until a cardholder sets one, they can enter any PIN at a payment terminal, or skip PIN entry if the terminal allows it. Once a PIN is set, the cardholder must enter the correct PIN for transactions that use PIN verification. Setting a PIN does not require PIN entry for every purchase; transactions that do not request a PIN continue to work without one. These APIs support **online PINs only**. Offline PINs, which are checked by a card's chip, are not currently supported. An online PIN is checked over the payment network when a payment requires it. "Online" describes how the PIN is checked; it does not mean the customer is shopping on a website. ## Use the hosted PIN-entry iframe **1. Configure your platform** Set `cardConfigs.pinTargetOrigin` once through `PATCH /config`: ```json theme={null} { "cardConfigs": { "pinTargetOrigin": "https://app.example.com" } } ``` Use the canonical HTTPS origin of the page that hosts the iframe: scheme, host, and optional port, without a path, trailing slash, credentials, query, or fragment. One origin covers all user-specific paths on that website. Omit this setting in later configuration updates to preserve it, or set it to `null` to clear it. Changes apply to newly requested URLs. **2. Get a PIN-entry URL** Call `GET /cards/{id}/pin-entry-url` from your backend immediately before displaying the form. Send no request body or origin parameter; Grid uses your platform's configured origin. If the origin is unset, the request returns `409 CONFLICT`. Grid returns `iframeUrl`, `sessionToken`, `expiresAt`, and `environment`. The URL already contains the temporary token and selects the appropriate environment. To customize the PIN input, pass the optional `styles` query parameter described below. Each request creates a fresh, temporary PIN-entry session and returns `Cache-Control: no-store`. Requesting a URL does not set or change the PIN. **3. Load the form** Before setting the iframe's `src`, register a `message` listener on your page. Only accept messages whose `origin` equals `new URL(iframeUrl).origin` and whose `source` equals your iframe's `contentWindow`; also require `data.embedType` to equal `PIN_SETTING`. Set the iframe's `src` to `iframeUrl` exactly as returned. Wait for `data.messageType` to be `Embed:Rendered` before enabling your submit button. The cardholder enters a four-digit PIN directly into this secure form, so the plaintext PIN never reaches your servers or Grid's. **4. Submit the PIN** When the cardholder clicks your submit button, send `{ messageType: "Embed:SubmitPin", embedType: "PIN_SETTING" }` to the iframe's `contentWindow` using `postMessage`. Set the target origin to `new URL(iframeUrl).origin`; never use `*`. Disable the button while submission is in progress. **5. Confirm the result** Listen for `Embed:PinSubmissionStatusChanged` with the following `data.status` values: | Status | Your app's action | | - | - | | `STARTED` | Keep submission disabled while waiting for a result. | | `SUCCEEDED` | Show confirmation, remove the iframe, and refresh the card's PIN status. | | `FAILED` | Show a PIN-entry error and let the cardholder correct the input. Request a new URL if the session is expired. | Remove your event listener when you remove the iframe. If you stop waiting before a result arrives, treat the outcome as unknown: do not report success or automatically resubmit. An `OK` status alone cannot prove that an already configured PIN was changed. The session permits one successful submission and expires at `expiresAt`. Request a new URL for another PIN change or after expiration. Treat both the URL and token as secrets: never persist, cache, log, or send them to analytics. ## Customize the PIN input Pass CSS declarations as a JSON string in the `styles` query parameter of `GET /cards/{id}/pin-entry-url`. Use CSS property names such as `font-size`, with string values. You can include nested declarations for focus, hover, and other supported pseudo-classes. For example, request a PIN input with your colors, font size, and border radius: ```bash theme={null} curl --get 'https://api.lightspark.com/grid/2025-10-13/cards/Card:YOUR_CARD_ID/pin-entry-url' \ --user "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ --data-urlencode 'styles={"color":"#222222","font-size":"16px","border-radius":"8px",":focus":{"border-color":"#0066ff"}}' ``` URL-encode the JSON as one query parameter; do not base64-encode it. Grid includes the styles in the returned `iframeUrl`, so you still set your iframe's `src` to that URL unchanged. Styles apply only to that request. Omit `styles` to use the default appearance. Supported pseudo-classes are `:first-child`, `:last-child`, `:enabled`, `:read-write`, `:read-only`, `:placeholder-shown`, `:valid`, `:invalid`, `:disabled`, `:hover`, `:focus-within`, `:focus`, `:focus-visible`, and `:active`. Each pseudo-class contains CSS declarations with string values; arbitrary selectors and further nesting are not supported. Styles that load external resources, including external fonts and images, are not supported. The JSON string is limited to 3072 characters. Its compact UTF-8 representation must fit within 4096 characters after base64 encoding; non-ASCII characters can consume more space. Invalid JSON, an invalid structure, or oversized styles return `400 INVALID_INPUT`. You control the surrounding layout and submit button on your page. Styling the outer iframe controls its size and border; use `styles` to customize the PIN input inside it. ## Encrypt a PIN in your own UI If you collect the PIN in your own UI, you **must encrypt it in the customer’s browser or mobile app before sending it to your server or Grid**. Download the [PIN encryption public key](/keys/pin-encryption-public-key.pem.txt). This PEM-encoded RSA public key is the same for sandbox and production; save it as `pin-encryption-public-key.pem` in your client integration. 1. Collect a four-digit PIN as a string so leading zeros are preserved. 2. Generate a fresh, cryptographically random integer `nonce` for each request. 3. Serialize an object containing `nonce` and `pin` as JSON. Encode the JSON as UTF-8. 4. Encrypt those bytes using the PIN encryption public key, then base64-encode the ciphertext. 5. Send the base64 string as `encryptedPinBlock` in `POST /cards/{id}/set-pin`. For example, a PIN of `0123` produces JSON with this shape **before encryption**: ```json theme={null} { "nonce": 582306194725183, "pin": "0123" } ``` Generate your own nonce each time; do not reuse the example. Encrypt the entire JSON payload. Never send this plaintext object to your server or Grid, and keep both plaintext and ciphertext out of logs, analytics, and persistent storage. Grid cannot decrypt the block. A rejected block returns `400 INVALID_INPUT`. Check the payload, encoding, and public key, then create a new encrypted block with a fresh nonce. A `204` response means the PIN change was accepted; read `pinStatus` from `GET /cards/{id}` to check its resulting state. ## Check and recover a PIN Read `pinStatus` from `GET /cards/{id}`: | Status | Next action | | - | - | | `NOT_SET` | No PIN is configured. The cardholder can choose a PIN to enable PIN verification. | | `OK` | The PIN is configured. | | `BLOCKED` | Authenticate the cardholder and offer a PIN change or unblock. | Three consecutive incorrect attempts block an online PIN. Call `POST /cards/{id}/pin/unblock` to keep the same PIN, or use either PIN-entry flow to choose a new one. Unblocking a PIN that is already `OK` is safe; a card with no PIN returns `409 CONFLICT`. Only request an unblock after authenticating the cardholder and receiving their explicit request. Do not automatically unblock after a declined transaction. Each card allows one unblock attempt per rolling seven days. If the PIN blocks again before that window ends, the endpoint returns `409 PIN_CHANGE_REQUIRED`. Use either PIN-entry flow to change the PIN and recover immediately, or wait until `details.pinUnblockAvailableAt`. Changing the PIN does not reset the unblock allowance. You can also read `pinUnblockAvailableAt` from the Card resource; it is absent until an attempt consumes the allowance. There is no PIN-reveal API. If the cardholder forgets the PIN, let them set a new one. Setting a PIN does not activate a closed or frozen card or change its spending limits. `Card.pinStatus` is the last known PIN status, populated when a card with PIN management is created. An absent value means PIN management is unavailable for this card; it does not mean `NOT_SET` or tell you whether a payment requires a PIN. ## Receive PIN status changes Handle `CARD.PIN_STATUS_CHANGE` at your webhook endpoint to keep your app's PIN status up to date. The event contains the updated `Card` resource in `data`, including `pinStatus`. * `NOT_SET → OK`: a PIN was configured. * `OK → BLOCKED`: the PIN became blocked. * `BLOCKED → OK`: the PIN was unblocked or replaced. Grid sends this event when its saved status changes, including changes detected after an iframe submission or a PIN API request. Initial card creation and replacing a PIN while the status stays `OK` do not trigger it. Use the iframe submission result or PIN API response to confirm a PIN replacement. Verify the webhook signature and deduplicate deliveries by event `id`. Deliveries may arrive out of order; use `data.updatedAt` to avoid overwriting a newer card snapshot. See [Card webhooks](/cards/platform-tools/webhooks) for payloads and retry behavior. ## Offline PIN cards are not supported An offline PIN is checked by a physical card's chip. Physical cards can also use online PINs; the distinction is where the PIN is checked. # Velocity Limits Source: https://agents-docs.lightspark.com/cards/card-management/velocity-limits Manage card and platform amount and transaction-count limits Use `/card-velocity-limits` to manage card authorization amount and count caps. Card issuance, card updates, and platform configuration do not configure velocity limits. ## Before you start Set `GRID_BASE_URL` to your versioned Grid API base URL and authenticate with your platform's `GRID_CLIENT_ID` and `GRID_CLIENT_SECRET`. Writes require `UMA_MANAGE`; reads require `UMA_VIEW`. Check `cardCapabilities.supportsVelocityLimits` on the card or funding account. These limits apply where Grid makes authorization decisions. Cards where the issuer makes the decision do not support this API. Issuer-enforced caps remain independent constraints. ## Choose a scope Omit `cardId`, or send `null`, to create a platform default. It applies separately to every eligible card, including cards issued later; usage is never pooled across cards. Configure platform defaults before issuing cards. Supply `cardId` to configure one card. Only your platform's cards and rules are accessible. Lightspark-managed rules are not returned by this API. For each metric and window, the first configured scope applies: card, then platform, then program. Amount and count inherit separately; different rolling durations are distinct windows. An unset cap inherits, while zero is configured and blocks. ## Create an amount or count limit Each rule selects one metric and one window, with `maxAmount`, `maxCount`, or both. At least one cap is required. Omit a cap, or send `null`, to leave it unset on that rule. Zero is a configured cap that blocks applicable new authorizations, including zero-amount checks. Amounts use the smallest unit of each card's currency, without currency conversion. USD- and USDB-funded USD cards both use USD cents: `50000` means \$500. Do not supply a `currency` field. | Control | Metric | Window | Cap | | - | - | - | - | | Per-authorization amount | `APPROVED` | `AUTHORIZATION` | `maxAmount` | | Daily amount | `APPROVED` | `DAY` | `maxAmount` | | Daily approved count | `APPROVED` | `DAY` | `maxCount` | | Attempts in a trailing interval | `ATTEMPTS` | `ROLLING` | `maxCount` | `APPROVED` counts distinct approved authorizations, including zero-amount approvals. Amount usage is holds less releases: reversals and expiries restore amount capacity, while clearing against a hold does not. Releases do not remove an approved authorization from the count. `ATTEMPTS` counts real-time requests, including declined requests, and supports count caps only. Calendar windows are UTC: `DAY` begins at midnight, `WEEK` on Monday, `MONTH` on the first, `QUARTER` in January/April/July/October, and `YEAR` on January 1. `ROLLING` requires `windowSeconds` between 1 and 2678400 (31 days). `AUTHORIZATION` accepts an amount cap only. ### Platform daily default ```bash theme={null} curl -X POST "$GRID_BASE_URL/card-velocity-limits" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "name": "Daily card budget", "metric": "APPROVED", "window": "DAY", "maxAmount": 50000, "maxCount": 20 }' ``` The response is `201 Created`: ```json theme={null} { "id": "CardVelocityRule:019542f5-b3e7-1d02-0000-000000000004", "name": "Daily card budget", "metric": "APPROVED", "window": "DAY", "maxAmount": 50000, "maxCount": 20 } ``` ### Card-specific authorization cap ```bash theme={null} curl -X POST "$GRID_BASE_URL/card-velocity-limits" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "cardId": "Card:019542f5-b3e7-1d02-0000-000000000010", "name": "Single authorization budget", "metric": "APPROVED", "window": "AUTHORIZATION", "maxAmount": 10000 }' ``` An authorization exactly equal to the cap is allowed. Later settlement of a previously authorized transaction can still complete above it, such as a restaurant tip. Authorization amount caps also apply to asynchronous authorizations; attempt counts apply to real-time requests only. ## List and retrieve limits `GET /card-velocity-limits` lists platform defaults. Add `cardId` to list only rules configured directly on that card. A card list does not include inherited platform or Lightspark limits. Use `limit` and `cursor` for pagination. ```bash theme={null} curl "$GRID_BASE_URL/card-velocity-limits?cardId=Card:019542f5-b3e7-1d02-0000-000000000010&limit=50" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ```bash theme={null} curl "$GRID_BASE_URL/card-velocity-limits/CardVelocityRule:019542f5-b3e7-1d02-0000-000000000004" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ## Replace or remove a limit Creating a rule with the same target, metric, window, and rolling duration returns `409 CONFLICT`, even if its name or caps differ. The existing rule stays unchanged. Different windows, durations, metrics, and card/platform scopes may coexist. Each target can hold at most 50 rules. ```json theme={null} { "code": "CONFLICT", "reason": "Rule CardVelocityRule:019542f5-b3e7-1d02-0000-000000000004 already exists for this card/platform, metric, window, and rolling duration. Delete it before creating a replacement." } ``` Delete the existing rule, then create its replacement: ```bash theme={null} curl -X DELETE "$GRID_BASE_URL/card-velocity-limits/CardVelocityRule:019542f5-b3e7-1d02-0000-000000000004" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Deletion returns `204 No Content`. Delete and create are separate operations: other applicable limits govern authorizations between requests, and failed creation does not restore the deleted rule. No update endpoint is available. Deleting a rule does not change decisions already recorded for earlier authorization requests. ## Other card controls Card freezing, merchant-category restrictions, risk-score ceilings, and agent-wide spending policies remain separate controls. Agent policy budgets cover sends and purchases across an agent's cards; these velocity rules track each card separately. The platform manages velocity limits with platform credentials. Agents can freeze their cards or tighten merchant categories through `PATCH /agents/me/cards/{cardId}`. # Playground Source: https://agents-docs.lightspark.com/cards/demo Issue a branded Visa debit card and watch the API calls fire as you go # Cards Source: https://agents-docs.lightspark.com/cards/index Issue debit cards, decision authorizations in real time, and reconcile transactions against internal accounts. You can issue debit cards backed by an internal account, decision authorizations in real time against that account's balance, and reconcile every pull, clearing, and refund against the same ledger you already use for payouts. Cards have no PIN set by default. Until a cardholder sets one, they can enter any PIN at a payment terminal, or skip PIN entry if the terminal allows it. Once a PIN is set, the cardholder must enter the correct PIN for transactions that use PIN verification. Setting a PIN does not require PIN entry for every purchase; transactions that do not request a PIN continue to work without one. See [Card PINs](/cards/card-management/pins) to let cardholders set, change, or unblock a PIN. ![](/images/icons/lightning.svg) Real-time decisioning Authorizations are checked against the bound internal account at auth time, so the funding source and the card share a single source of truth. ![](/images/icons/shield.svg) No PAN in your stack The full PAN and CVV are rendered directly to the cardholder through the issuer's iframe. Card credentials never cross your servers. ## Card lifecycle at a glance A card moves through six states: | State | Meaning | | - | - | | `PENDING_KYC` | Cardholder has not finished KYC; the card cannot transact yet. | | `PROCESSING` | Card has been requested and is being provisioned with the issuer. | | `PENDING_AUTH` | Issuer provisioning is complete; USDB Embedded Wallet funding requires delegated signing authorization. | | `ACTIVE` | Card is live and can authorize transactions. | | `FROZEN` | Card is temporarily disabled. New authorizations are declined; in-flight settlements continue. | | `CLOSED` | Card is permanently closed. Terminal, irreversible. | Every transition emits a `CARD.STATUS_CHANGE` webhook so you can mirror state changes into your application. ## What's covered in this tab > [card] [Quickstart](/cards/quickstart) > > Issue your first card, simulate an authorization, and watch it > reconcile against an internal account. > [card] [Card management](/cards/card-management/issuing-cards) > > Issue cards, bind a funding source, freeze, unfreeze, and close. > [card] [Transactions & reconciliation](/cards/transactions/reconciliation) > > The relationship between authorizations, pulls, clearings, and > refunds — and how to surface exceptions. > [card] [Sandbox testing](/cards/platform-tools/sandbox-testing) > > Drive deterministic outcomes with the magic-value suffix tables. > [card] [Card design guidelines](/cards/card-design/guidelines) > > Specs, templates, and rules for designing a physical card and its > digital wallet art. # Cardholder Setup Source: https://agents-docs.lightspark.com/cards/onboarding/cardholder-setup Prepare a customer to receive a card Before you can issue a card, the cardholder must be a Grid `Customer` in good standing with a funded internal account. This page covers the requirements and the order they must be satisfied in. ## KYC must be APPROVED `POST /cards` is rejected with `400 CARDHOLDER_KYC_NOT_APPROVED` if the cardholder's `kycStatus` is anything other than `APPROVED`. There is no "issue and verify later" path. If you're a regulated platform that creates customers directly with KYC data, the customer reaches `APPROVED` as soon as the verification returns approved. If you're using the hosted KYC link flow, gate issuance on the `CUSTOMER.KYC_APPROVED` webhook. ```bash theme={null} # Check KYC status before issuing curl -X GET "$GRID_BASE_URL/customers/Customer:019542f5-b3e7-1d02-0000-000000000001" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ## The cardholder needs a funding source Every card must be bound to one `InternalAccount` at issue time. The account must: * Belong to the cardholder (no cross-customer funding in v1). * Be denominated in a card-eligible currency. In v1 this is USDB; the request is rejected with `400 INVALID_INPUT` otherwise. If the cardholder doesn't have an internal account yet, internal accounts are created automatically when the customer is created based on your platform configuration. You can list them with: ```bash theme={null} curl -X GET "$GRID_BASE_URL/customers/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001¤cy=USDB" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ## Pre-fund before authorizations arrive Grid reserves the authorized amount from the funding source when it decides an authorization. If the source cannot cover it, the authorization is declined with `cardDeclinedReason: INSUFFICIENT_FUNDS`. Fund the source the same way you would for any other internal account — via the funding payment instructions or, in Sandbox, with `/sandbox/internal-accounts/{id}/fund`. > [!NOTE] > > Just-in-time funding works the same as for other Grid flows: receive a > deposit into the funding source, let it confirm, then expect the > cardholder to transact. There is no separate JIT path for cards in v1. ## Ready to issue Once the cardholder is `APPROVED` and a funded internal account exists, issue the card with `POST /cards`. See [Issuing cards](/cards/card-management/issuing-cards) for the request shape and lifecycle states. # Implementation Overview Source: https://agents-docs.lightspark.com/cards/onboarding/implementation-overview End-to-end architecture for issuing and operating cards This page gives you a 10,000-ft view of an end-to-end Cards implementation. The detailed guides that follow cover concrete request shapes, edge cases, and step-by-step instructions. > [!NOTE] > > Cards sit on top of the same customer and internal-account primitives > you already use for payouts. If you've already onboarded customers and > funded internal accounts in Grid, the work to add cards is small. ## Platform configuration You need an existing Grid platform configuration before you can issue cards. Cards do not require new webhook endpoints or new API credentials — they reuse what's already configured for the rest of Grid. You'll only need to: * Subscribe to the card webhook types: `CARD.STATUS_CHANGE` for card state, `CARD.PIN_STATUS_CHANGE` for PIN status, and `CARD_TRANSACTION.AUTHORIZED`, `CARD_TRANSACTION.PARTIALLY_SETTLED`, `CARD_TRANSACTION.SETTLED`, `CARD_TRANSACTION.DECLINED`, `CARD_TRANSACTION.VOIDED`, and `CARD_TRANSACTION.EXCEPTION` for the transaction lifecycle. * Confirm with your Lightspark contact that cards are enabled for your platform — issuance requires an issuer-side onboarding. * If your program includes physical cards, start the artwork early: design review, Visa approval, and manufacturing run for weeks. See [Card design guidelines](/cards/card-design/guidelines). ## Cardholder readiness A card can only be issued to a `Customer` with `kycStatus: APPROVED`. This is the same gate you use for Grid's other features. If the cardholder hasn't completed KYC, `POST /cards` returns `400 CARDHOLDER_KYC_NOT_APPROVED` — see [Cardholder setup](/cards/onboarding/cardholder-setup) for how to drive KYC to completion before issuing. ## Funding sources Every card is bound to one `InternalAccount` as its funding source at issue time. Authorization Decisioning checks the source's balance before approving each auth, so: * Top up the funding source before you expect transactions. * Use existing funding instructions (ACH, SEPA, wires, stablecoin) the same way you would for any other internal account. * See [Funding sources](/cards/card-management/funding-sources) for rules around binding and replacing the source. * A card funded from a customer's Embedded Wallet also needs a delegated signing key, created once by the cardholder, before authorizations can pull from it. See [Embedded Wallet funding](/cards/card-management/embedded-wallet-funding). ## Issuing and lifecycle Issuance is a single `POST /cards` call. New cards start in `PROCESSING` while the issuer provisions them and transition to `ACTIVE` automatically — you observe both transitions via the `CARD.STATUS_CHANGE` webhook. Day-to-day operational changes are: * `PATCH /cards/{id}` to freeze, unfreeze, or close permanently (`status: "CLOSED"`). Both freeze and close use Grid's `202 → signed-retry` pattern (the same pattern as Embedded Wallet credential revocation). See [Freezing and closing cards](/cards/card-management/freezing-and-closing). Cardholders can add a card to Apple Pay, Google Pay, or Samsung Pay by typing its details into the wallet with no work on your side. An in-app "Add to Wallet" button needs wallet-provider approval plus one `POST /cards/{id}/tokenize` call from your backend. See [Digital wallet tokenization](/cards/card-management/digital-wallet-tokenization). ## Transactions and reconciliation Each authorization on a card produces a `CardTransaction` row, with clearings reconciled against it and rolled up into `settledAmount`. The lifecycle status moves `AUTHORIZED → PARTIALLY_SETTLED → SETTLED`, with `DECLINED` for auths refused before approval, `VOIDED` for approved auths fully reversed or expired before clearing, and `EXCEPTION` as the failure path for stuck post-hoc pulls. A merchant return is its own dated `CREDIT` `CardTransaction` row linked to the purchase via `originalTransactionId`; the purchase keeps its `SETTLED` status. The full event model is covered in [Reconciliation](/cards/transactions/reconciliation). ## Testing in Sandbox Sandbox cannot receive real authorizations from the card network, so it exposes three simulate endpoints that drive the same internal paths the issuer would call in production: * `POST /sandbox/cards/{id}/simulate/authorization` * `POST /sandbox/cards/{id}/simulate/clearing` * `POST /sandbox/cards/{id}/simulate/return` Outcomes are deterministic — driven by magic-value suffixes on the relevant id. See [Sandbox testing](/cards/platform-tools/sandbox-testing). ## Enabling Production When you're ready to go live: * Complete card-issuer onboarding through your Lightspark contact. * Confirm webhook security, monitoring, and alerting cover the `CARD.*` and `CARD_TRANSACTION.*` event types. * Build the `EXCEPTION` dashboard view from `CARD_TRANSACTION.EXCEPTION` webhooks and wire it into on-call alerting. > [!TIP] > > Contact your Lightspark representative to enable Production card > issuance and finalize issuer activations. ## USDB funding authorization A provisioned card funded by a USDB Embedded Wallet is `PENDING_AUTH` until all signed retries of `POST /auth/delegated-keys` are complete. Successful delegation restores `ACTIVE`; funding changes or key revocation can return an active card to `PENDING_AUTH`. Frozen and closed cards keep their status when delegation completes. # Sandbox Testing Source: https://agents-docs.lightspark.com/cards/platform-tools/sandbox-testing Drive deterministic card outcomes with the simulate endpoints The card network can't reach into Sandbox to send real authorizations, so Sandbox exposes three simulate helpers that drive the same internal `authorize` and `reconcile` paths the issuer would call in production. Production returns `404` on these paths. ```text theme={null} POST /sandbox/cards/{id}/simulate/authorization POST /sandbox/cards/{id}/simulate/clearing POST /sandbox/cards/{id}/simulate/return ``` Authorization outcomes are deterministic and driven by magic-value suffixes on `merchant.descriptor`. Other Sandbox helpers use the identifier suffixes described in their sections, including `/sandbox/internal-accounts/{id}/fund`. ## Funding-source suffixes (accountId) Binding a funding source resolves based on the last three characters of `accountId`: | Suffix | Behavior | | - | - | | `002` | `INVALID_INPUT` (insufficient balance check) | | `003` | `INVALID_INPUT` (account closed) | | any other | Success | ## Authorization simulate ```bash theme={null} curl -X POST "$GRID_BASE_URL/sandbox/cards/Card:019542f5-b3e7-1d02-0000-000000000010/simulate/authorization" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "amount": 1250, "currency": { "code": "USD" }, "merchant": { "descriptor": "BLUE BOTTLE COFFEE SF", "mcc": "5814", "country": "US" } }' ``` Outcomes are controlled by the last three characters of `merchant.descriptor`: | Suffix | Outcome | | - | - | | `002` | Declined, `cardDeclinedReason: INSUFFICIENT_FUNDS`, as production declines an authorization the funding source cannot cover | | `003` | Declined, `cardDeclinedReason: CARD_NOT_ACTIVE`. Use against a `FROZEN` card to verify it refuses authorizations | | `005` | Delayed pull (\~30s) — exercises the `PENDING → CONFIRMED` path | | `006` | Pull succeeds but the confirmation event reports `FAILED` — exercises the high-urgency `EXCEPTION` alert | | any other | Approved | `merchant.descriptor` must be 1–25 characters — the width of the card network's acceptor-name field. A longer one is rejected with `400` `INVALID_INPUT`. The response is the resulting `CardTransaction`. ## Clearing simulate ```bash theme={null} curl -X POST "$GRID_BASE_URL/sandbox/cards/Card:019542f5-b3e7-1d02-0000-000000000010/simulate/clearing" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "cardTransactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000100", "amount": 1500 }' ``` * `amount > authorizedAmount` exercises the over-auth post-hoc pull path (restaurant tip / tip-on-top). * `amount = 0` exercises `AUTHORIZATION_EXPIRY` — the auth expires with no clearing posted, moves to `VOIDED`, and sends `CARD_TRANSACTION.VOIDED`. Suffix-driven outcomes on the parent transaction's id govern whether the post-hoc pull succeeds — use them with the [merchant descriptor suffixes](#authorization-simulate) above to construct deterministic exception scenarios. ## Return simulate ```bash theme={null} curl -X POST "$GRID_BASE_URL/sandbox/cards/Card:019542f5-b3e7-1d02-0000-000000000010/simulate/return" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "cardTransactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000100", "amount": 1500 }' ``` A refund posts as its own dated `CREDIT` `CardTransaction` linked to the purchase via `originalTransactionId`; the purchase keeps its `SETTLED` status, whether the refund is full or partial. ## End-to-end happy path A simple Sandbox loop that exercises issue → activate → auth → clear → refund: ```bash theme={null} # 1. Issue a virtual card curl -X POST "$GRID_BASE_URL/cards" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerId": "Customer:...", "form": "VIRTUAL", "fundingSource": "InternalAccount:..." }' # 2. Simulate auth — any non-magic descriptor approves curl -X POST "$GRID_BASE_URL/sandbox/cards/Card:.../simulate/authorization" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "amount": 1250, "currency": {"code":"USD"}, "merchant": {"descriptor":"BLUE BOTTLE COFFEE SF"} }' # 3. Clear the auth curl -X POST "$GRID_BASE_URL/sandbox/cards/Card:.../simulate/clearing" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "cardTransactionId": "Transaction:...", "amount": 1500 }' # 4. Refund the cleared transaction curl -X POST "$GRID_BASE_URL/sandbox/cards/Card:.../simulate/return" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "cardTransactionId": "Transaction:...", "amount": 1500 }' ``` At each step you'll see `CARD.STATUS_CHANGE` webhooks plus transaction webhooks for the simulated authorization, clearing, and return — wire those into your local webhook handler to validate end-to-end. # Webhooks Source: https://agents-docs.lightspark.com/cards/platform-tools/webhooks Card webhook events and how to consume them Cards add eight webhook event types on top of Grid's existing webhook infrastructure. Signature verification (`X-Grid-Signature`) and retry behavior are identical to the rest of Grid — see [Authentication](/api-reference/authentication) and [Webhooks](/api-reference/webhooks) for the underlying mechanics. Two cover the card itself; the others cover a card transaction's lifecycle. ## Event types | Type | Fires on | | - | - | | `CARD.STATUS_CHANGE` | `PROCESSING → ACTIVE / PENDING_AUTH`, `ACTIVE ⇄ PENDING_AUTH`, `→ CLOSED (ISSUER_REJECTED)`, and every subsequent `ACTIVE ⇄ FROZEN`, `PENDING_AUTH → FROZEN`, and `→ CLOSED` transition. | | `CARD.PIN_STATUS_CHANGE` | Grid records a new PIN status, such as `NOT_SET → OK`, `OK → BLOCKED`, or `BLOCKED → OK`. | | `CARD_TRANSACTION.AUTHORIZED` | An authorization is approved and a hold is placed on the funding source. | | `CARD_TRANSACTION.PARTIALLY_SETTLED` | A clearing posted, but more are still expected. | | `CARD_TRANSACTION.SETTLED` | All clearings have posted. Also fires for a merchant return, which posts as its own `CREDIT` row linked via `originalTransactionId`. | | `CARD_TRANSACTION.DECLINED` | The authorization was declined before any money moved. The payload carries `cardDeclinedReason`. | | `CARD_TRANSACTION.VOIDED` | An approved authorization was fully reversed or expired before any clearing posted. | | `CARD_TRANSACTION.EXCEPTION` | The transaction settled to the network but the pull from the funding source failed. | All of them carry the standard envelope: ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000020", "type": "CARD.STATUS_CHANGE", "timestamp": "2026-05-08T14:11:00Z", "data": { /* the affected Card or CardTransaction resource */ } } ``` The `id` identifies an event and stays the same across delivery retries. Use it for idempotency. If you turn on real-time authorization, Grid also sends `CARD_AUTHORIZATION.REQUEST`, a real-time decision request that your handler must answer with a decision body. See [Real-time authorization](/cards/transactions/real-time-authorization). ## CARD.STATUS\_CHANGE The `data` payload is the post-change `Card` resource. Example — activation after issuance: ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000020", "type": "CARD.STATUS_CHANGE", "timestamp": "2026-05-08T14:11:00Z", "data": { "id": "Card:019542f5-b3e7-1d02-0000-000000000010", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "status": "ACTIVE", "brand": "VISA", "form": "VIRTUAL", "last4": "4242", "expMonth": 12, "expYear": 2029, "fundingSource": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "currency": "USD", "processorRef": "card_b81c2a4f", "issuerRef": "lead_card_7a1b9c3d", "createdAt": "2026-05-08T14:10:00Z", "updatedAt": "2026-05-08T14:11:00Z" } } ``` Common branches to handle in your consumer: * `status: "PENDING_AUTH"` — complete delegated-key setup for the funding source. * `status: "ACTIVE"` after `PROCESSING` or `PENDING_AUTH` — the card is live. To reveal the full card details, request a reveal with `POST /cards/{id}/reveal` right before rendering its short-lived `panEmbedUrl` in an iframe — webhook payloads never carry a reveal URL. * `status: "CLOSED"`, `statusReason: "ISSUER_REJECTED"` — the issuer rejected provisioning; offer to issue a new card. * `status: "FROZEN"` / `status: "ACTIVE"` — reflect the freeze toggle in your UI. * `status: "CLOSED"`, `statusReason: "CLOSED_BY_PLATFORM"` — close confirmed; stop showing the card. ## CARD.PIN\_STATUS\_CHANGE The `data` payload is the updated `Card` resource, including `pinStatus`. Use it to offer PIN setup when the status is `NOT_SET`, reflect a configured PIN when it is `OK`, or offer recovery when it is `BLOCKED`. This event fires when Grid's saved PIN status changes, whether the change is observed through a PIN API request or an asynchronous update after an iframe submission or an incorrect PIN attempt. Creating a card does not fire it. Replacing an existing PIN while its status stays `OK` also does not fire it; use the submission result to confirm that change. The payload never includes a PIN, encrypted PIN block, or session credential. Delivery can lag behind a change or arrive out of order. Compare `data.updatedAt` before replacing a newer card snapshot. Read `pinStatus` from `GET /cards/{id}` to retrieve the card's saved status. See [Card PINs](/cards/card-management/pins) for the setup and recovery flows. ## Card-transaction lifecycle The `data` payload is the whole post-change `CardTransaction` resource, the same shape `GET /transactions` returns for a `CARD` row, so you can upsert it by `data.id` without a follow-up read. Example — authorization approved: ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000040", "type": "CARD_TRANSACTION.AUTHORIZED", "timestamp": "2026-05-09T10:00:00Z", "data": { "type": "CARD", "id": "Transaction:019542f5-b3e7-1d02-0000-000000000100", "cardId": "Card:019542f5-b3e7-1d02-0000-000000000010", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "platformCustomerId": "18d3e5f7b4a9c2", "issuerTransactionToken": "lithic_txn_b81c2a4f", "status": "AUTHORIZED", "direction": "DEBIT", "description": "BLUE BOTTLE COFFEE SF", "merchant": { "descriptor": "BLUE BOTTLE COFFEE SF", "mcc": "5814", "country": "US" }, "authorizedAmount": { "amount": 12550, "currency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 } }, "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "authorizedAt": "2026-05-09T10:00:00Z", "createdAt": "2026-05-09T10:00:00Z", "updatedAt": "2026-05-09T10:00:00Z" } } ``` Later deliveries for this approved authorization carry the updated resource: `settledAmount` appears once a clearing posts, and `status` moves to `PARTIALLY_SETTLED`, `SETTLED`, `VOIDED`, or `EXCEPTION`. A declined authorization instead reports `DECLINED` as its terminal decision. A merchant return has no event type of its own — it arrives as a `CARD_TRANSACTION.SETTLED` delivery for a new `data.id` with `direction: "CREDIT"` and `originalTransactionId` pointing at the purchase, so key your handling on `data.id` rather than on the event type. See [Reconciliation](/cards/transactions/reconciliation) for the underlying event model. ## Idempotency & retries Webhook deliveries are at-least-once. Track processed `id` values and return `200` on duplicates, or return `409` and let Grid stop retrying. Both shapes are accepted by Grid's webhook infrastructure. # Quickstart Source: https://agents-docs.lightspark.com/cards/quickstart Issue your first card and simulate a transaction end to end This quickstart walks you from an empty Sandbox to a card transaction you can see in your dashboard. We'll: 1. Confirm the cardholder is KYC-approved. 2. Fund their internal account. 3. Issue a virtual card against that account. 4. Simulate an inbound authorization, then a clearing. 5. List the resulting `CardTransaction`. ## Prerequisites * Sandbox API credentials. * A `Customer` with `kycStatus: APPROVED` and at least one `InternalAccount`. If you don't have one yet, follow the [Payouts quickstart](/payouts-and-b2b/quickstart) up to "Get the Customer's Internal Account". ```bash theme={null} export GRID_BASE_URL="https://api.lightspark.com/grid/2025-10-13" export GRID_CLIENT_ID="YOUR_SANDBOX_CLIENT_ID" export GRID_CLIENT_SECRET="YOUR_SANDBOX_CLIENT_SECRET" ``` ## Fund the cardholder's internal account Cards decline at auth time if the bound funding source can't cover the transaction. Top up the cardholder's internal account first. ```bash theme={null} curl -X POST "$GRID_BASE_URL/sandbox/internal-accounts/InternalAccount:019542f5-b3e7-1d02-0000-000000000002/fund" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "amount": 50000 }' ``` ## Issue the card ```bash theme={null} curl -X POST "$GRID_BASE_URL/cards" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "form": "VIRTUAL", "fundingSource": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" }' ``` The card comes back in `status: "PROCESSING"` while the issuer provisions it. `platformCardId` is generated by the server, and any value supplied in the request is ignored. When activation completes, a `CARD.STATUS_CHANGE` webhook fires with `status: "ACTIVE"`: ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000020", "type": "CARD.STATUS_CHANGE", "timestamp": "2026-05-08T14:11:00Z", "data": { "id": "Card:019542f5-b3e7-1d02-0000-000000000010", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "status": "ACTIVE", "brand": "VISA", "form": "VIRTUAL", "last4": "4242", "expMonth": 12, "expYear": 2029, "fundingSource": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "currency": "USD", "processorRef": "card_b81c2a4f", "issuerRef": "lead_card_7a1b9c3d", "createdAt": "2026-05-08T14:10:00Z", "updatedAt": "2026-05-08T14:11:00Z" } } ``` > [!NOTE] > > To display the full PAN, CVV, and expiry to the cardholder, request a > reveal with `POST /cards/{id}/reveal` and render the returned > `panEmbedUrl` in an iframe. The URL is short-lived, so request it right > before rendering. The full card credentials never cross your servers. ## Simulate an authorization Sandbox exposes simulate endpoints that drive the same internal paths the card issuer would call in production. The decisioning outcome is controlled by the last three characters of `merchant.descriptor` — any non-magic suffix is approved. The descriptor must be 1–25 characters. ```bash theme={null} curl -X POST "$GRID_BASE_URL/sandbox/cards/Card:019542f5-b3e7-1d02-0000-000000000010/simulate/authorization" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "amount": 1250, "currency": { "code": "USD" }, "merchant": { "descriptor": "BLUE BOTTLE COFFEE SF", "mcc": "5814", "country": "US" } }' ``` The response is the resulting `CardTransaction` in `status: "AUTHORIZED"` with a single pull on the funding source. ## Simulate the clearing The merchant adds a tip and clears for more than the original auth ($15.00 on a $12.50 hold). Grid handles the over-auth by issuing a post-hoc pull for the difference. ```bash theme={null} curl -X POST "$GRID_BASE_URL/sandbox/cards/Card:019542f5-b3e7-1d02-0000-000000000010/simulate/clearing" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "cardTransactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000100", "amount": 1500 }' ``` A transaction webhook fires with the updated parent: the transaction moves to `SETTLED` and `settledAmount` is `1500`. The webhook payload carries the full `CardTransaction` with clearings rolled up into `settledAmount`; a merchant refund posts as its own dated `CREDIT` row linked via `originalTransactionId` — see [Reconciliation](/cards/transactions/reconciliation) for the full event model. > [!TIP] > > You've issued a card, watched it activate, and driven an over-auth > transaction through pull and clearing. ## USDB funding authorization A provisioned card funded by a USDB Embedded Wallet is `PENDING_AUTH` until all signed retries of `POST /auth/delegated-keys` are complete. Successful delegation restores `ACTIVE`; funding changes or key revocation can return an active card to `PENDING_AUTH`. Frozen and closed cards keep their status when delegation completes. # Periodic statements Source: https://agents-docs.lightspark.com/cards/statements What a periodic statement for a Grid account contains, and how to build one from Grid transaction data Every account held at Lead Bank gets a periodic statement each month, whether or not it had activity. Your platform agreement sets out what the statement contains and who delivers it. Use this page as a reference for a typical statement and how to build one from Grid data. This guide covers when to send a statement, what it contains, how to map Grid data to each statement field, and how to build one once the month settles. Consumer accounts carry a few extra items, called out where they apply. ## Sample statement layout Your statement's visual design is up to you. The layout below shows a consumer account statement with sample data filled in. Check your platform agreement for the fields and disclosures your program requires: September statement Opening balance \$2,450.00USD Closing balance \$3,373.95USD Transactions Amount Total fees for period \$15.00 * See below in case of errors or questions In case of errors or questions about your electronic transfers\* Telephone us at (855) 516-0103 or Write us at 8605 Santa Monica Blvd, PMB 64461, West Hollywood, CA 90069 as soon as you can, if you think your statement or receipt is wrong or if you need more information about a transfer on the statement or receipt. We must hear from you no later than 60 days after we sent you the FIRST statement on which the error or problem appeared.
          We will investigate your complaint and will correct any error promptly. If we take more than 10 business days to do this, we will credit your account for the amount you think is in error, so that you will have the use of the money during the time it takes us to complete our investigation. Report errors within 60 days after we send this statement. This account is held at Lead Bank, the account-holding institution. Lightspark is the program manager and is not a bank. The sample is a consumer statement. It carries three items a commercial statement omits: the asterisk on each line the error-resolution notice covers, the notice itself, and the terminal location on card purchases. Your platform agreement sets out which transaction types carry the marker. ## When to send a statement A statement period is a calendar month in US Central time (`America/Chicago`). Issue a statement for every account each month, with or without activity. Build the statement once the month's card settlement has closed. Until then, [List balance changes](/api-reference/periodic-statements/list-balance-changes-for-a-period) refuses the month with `409 NOT_YET_AVAILABLE`, because its figures could still change. Retry later. Send the statement to the account holder by email, in-app notification, or any channel that lets the account holder **retain** it. Then send Grid a receipt with [Send a receipt for a periodic statement](/api-reference/periodic-statements/send-a-receipt-for-a-periodic-statement), once a month for each account. Grid stores it as the delivery record for that account and month. Your platform agreement sets out who delivers the statement. > [!NOTE] > > Once you issue a statement, **freeze its contents**. A refund or return that posts after the month closes appears on a later statement as its own dated line. ACH returns often land in the following month. ## What a statement contains A statement has two parts: disclosure text, and fields you fill from the account and its transactions. ### Provider disclosures Every statement carries the provider line. A consumer statement also carries the error-resolution notice, which gives the phone number and mailing address. A commercial statement has no notice, so it carries a contact line instead. The sample uses the wording below; your platform agreement provides the disclosure text for your program. | Field | Value | | - | - | | Provider line | “This account is held at Lead Bank, the account-holding institution. Lightspark is the program manager and is not a bank.” | | Error-resolution notice | Consumer statements. The notice text shown in the sample above. | | Phone and address | Consumer statements: inside the error-resolution notice (“Telephone us at … or Write us at …”). Commercial statements: a line reading “Direct inquiries to 8605 Santa Monica Blvd, PMB 64461, West Hollywood, CA 90069 or (855) 516-0103.” | ### Statement fields | Field | Description | Included when | | - | - | - | | Account holder | Name of the account holder | Always | | Account type | Kind of account, for example “Consumer prepaid account” | Always | | Account number | Number that identifies the account, masked to the last four digits | Always | | Statement period | Start and end date of the period | Always | | Issue date | Date the statement is sent | Always | | Opening balance | Balance at the start of the period | Always | | Closing balance | Balance at the end of the period | Always | | Transaction date | Date each transaction posted to the account | Always | | Transaction type | Kind of transfer, for example ACH deposit, debit card purchase, wire transfer out | Always | | Payee or merchant | Counterparty name, or the merchant descriptor for a card purchase | Always | | Terminal location | Merchant city and state | Consumer statements, for card purchases at a merchant terminal, when the card network reports a location | | Transaction amount | Signed amount in the account currency | Always | | Total fees for the period | Sum of all fees charged in the period | Always | ## Mapping Grid data to statement fields You build a statement from four calls: * [Get customer by ID](/api-reference/customers/get-customer-by-id) for the account holder. * [List Customer internal accounts](/api-reference/internal-accounts/list-customer-internal-accounts) for the account. * [List balance changes](/api-reference/periodic-statements/list-balance-changes-for-a-period) for the month's lines and its opening and closing balances. * [Get transaction by ID](/api-reference/transactions/get-transaction-by-id) for each line's type, payee, and merchant. Pass `startDate` as the first instant of the month and `endDate` as the first instant of the next month, both in US Central time. Page with `cursor` while `hasMore` is `true`. The opening and closing balances describe the whole month, so they are the same on every page. All amounts are integers in the smallest unit of their currency (for example, cents), so format them using the currency's `decimals`. | Statement field | Grid source | | - | - | | Account holder | `fullName` on the customer (`customerType: INDIVIDUAL`) or `businessInfo.legalName` (`customerType: BUSINESS`) | | Account type | `type` on the internal account (`INTERNAL_FIAT` for the platform-managed fiat account), shown as a plain-language label | | Account number | `fundingPaymentInstructions[].accountOrWalletInfo.accountNumber` on the internal account, masked to the last four digits | | Statement period | The Central-time month you pass as `startDate` and `endDate` | | Issue date | You supply it: the date the statement is sent | | Opening balance | `openingBalance` from List balance changes | | Closing balance | `closingBalance` from List balance changes | | Transaction date | `effectiveAt` on each balance change: when the balance moved | | Transaction type | `type` (`INCOMING` / `OUTGOING` / `CARD`) and `direction` (`CREDIT` / `DEBIT`) on the change's transaction. On `OUTGOING` transactions, `paymentRail` names the rail | | Payee or merchant | `counterpartyInformation` or `description` on `INCOMING` and `OUTGOING` transactions. `merchant.descriptor` on `CARD` transactions | | Terminal location | `merchant.city` and `merchant.state` on `CARD` transactions, when present | | Transaction amount | `amount` on each balance change, already signed: money out is negative | | Total fees for the period | Sum of `fees` across the month's transactions, counting each transaction once. Subtract the fee of a transaction refunded in the month | **Check before you issue:** `openingBalance + Σ(amount) == closingBalance`, summed over every page. It holds by construction, so a mismatch means a page was missed. > [!NOTE] > > Each balance change is one movement of the balance, so one transaction can produce several lines. An ACH deposit and its later return are two lines. A card purchase that clears in two parts is two lines. A withdrawal's fee can be a line of its own. Several changes can share a `transactionId`, so fetch each transaction once. > > `transactionId` is `null` for an adjustment with no transaction behind it. List it with a generic description, such as "Account adjustment". ## Example: build a monthly statement Once the month has settled, read the account holder and account, list the month's balance changes, look up each line's transaction, render the statement, deliver it, and send Grid the receipt. ```javascript theme={null} // `grid` is your authenticated HTTP client for the Grid base URL. // ERROR_RESOLUTION_NOTICE, maskAccountNumber, and the delivery helpers are yours. // Follow nextCursor until hasMore is false async function* listAll(path, params) { let cursor; do { const query = new URLSearchParams({ ...params, limit: '100' }); if (cursor) query.set('cursor', cursor); const page = await grid.get(`${path}?${query}`); yield* page.data; cursor = page.hasMore ? page.nextCursor : undefined; } while (cursor); } // The first instant of a month in US Central time, as an ISO string with its offset function centralMonthStart(year, month) { const utcMidnight = new Date(Date.UTC(year, month - 1, 1)); const offset = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Chicago', timeZoneName: 'longOffset', }) .formatToParts(utcMidnight) .find((part) => part.type === 'timeZoneName') .value.replace('GMT', ''); return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00${offset}`; } function describe(transaction) { if (!transaction) return { type: 'ADJUSTMENT', payee: 'Account adjustment' }; if (transaction.type === 'CARD') { const { descriptor, city, state } = transaction.merchant; return { type: 'CARD', payee: descriptor, terminalLocation: city && state ? `${city}, ${state}` : undefined, }; } return { type: transaction.type, rail: transaction.paymentRail, payee: transaction.description, }; } async function buildStatement(customerId, accountId, year, month) { const customer = await grid.get(`/customers/${customerId}`); const commercial = customer.customerType === 'BUSINESS'; let account; for await (const candidate of listAll('/customers/internal-accounts', { customerId })) { if (candidate.id === accountId) { account = candidate; break; } } if (!account) throw new Error(`Account ${accountId} does not belong to customer ${customerId}`); const startDate = centralMonthStart(year, month); const endDate = month === 12 ? centralMonthStart(year + 1, 1) : centralMonthStart(year, month + 1); // A 409 NOT_YET_AVAILABLE means the month has not settled yet: retry later const path = `/internal-accounts/${accountId}/balance-changes`; const first = await grid.get(`${path}?${new URLSearchParams({ startDate, endDate, limit: '1' })}`); const { openingBalance, closingBalance } = first; const changes = []; for await (const change of listAll(path, { startDate, endDate })) changes.push(change); const movement = changes.reduce((sum, change) => sum + change.amount.amount, 0); if (openingBalance.amount + movement !== closingBalance.amount) { throw new Error('Statement does not reconcile: opening balance plus lines is not the closing balance'); } // Several changes can share one transaction: fetch each once const transactions = new Map(); for (const { transactionId } of changes) { if (transactionId && !transactions.has(transactionId)) { transactions.set(transactionId, await grid.get(`/transactions/${transactionId}`)); } } const lines = []; for (const change of changes) { const detail = describe(transactions.get(change.transactionId)); if (commercial) delete detail.terminalLocation; lines.push({ date: change.effectiveAt, ...detail, amount: change.amount.amount }); } // Count each transaction's fee once; a refund in the month gives it back let totalFees = 0; for (const [transactionId, transaction] of transactions) { if (!transaction.fees || transaction.source?.accountId !== accountId) continue; const own = changes.filter((change) => change.transactionId === transactionId); if (own.some((change) => change.amount.amount < 0)) totalFees += transaction.fees; if (own.some((change) => change.amount.amount > 0)) totalFees -= transaction.fees; } const { currency } = closingBalance; const format = (amount) => (amount / 10 ** currency.decimals).toFixed(currency.decimals); const statement = { // Disclosures: a consumer statement carries the notice, a commercial statement the contact line providerLine: 'This account is held at Lead Bank, the account-holding institution. ' + 'Lightspark is the program manager and is not a bank.', ...(commercial ? { contactLine: 'Direct inquiries to 8605 Santa Monica Blvd, PMB 64461, West Hollywood, CA 90069 or (855) 516-0103.' } : { errorResolutionNotice: ERROR_RESOLUTION_NOTICE }), // the notice includes the phone number and mailing address // Account fields accountHolder: commercial ? customer.businessInfo.legalName : customer.fullName, accountType: account.type, // render as a label, for example "Consumer prepaid account" accountNumber: maskAccountNumber(account.fundingPaymentInstructions), period: { startDate, endDate }, issueDate: new Date().toISOString(), openingBalance: format(openingBalance.amount), closingBalance: format(closingBalance.amount), lines: lines.map((line) => ({ ...line, amount: format(line.amount) })), totalFees: format(totalFees), }; await sendStatementEmail(customer.platformCustomerId, statement); // your delivery channel // The monthly receipt: Grid's record that this account's statement was issued await grid.post(`/internal-accounts/${accountId}/confirm-statement`, { statementMonth: `${year}-${String(month).padStart(2, '0')}`, statementDeliveredAt: statement.issueDate, }); } ``` # Terminology Source: https://agents-docs.lightspark.com/cards/terminology Concepts and resources specific to the Cards API The Cards API builds on top of the entities already used by the rest of Grid (Platform, Customer, Internal Account). The terms below are the ones that are new or have a card-specific meaning. ## Cardholder The `Customer` that a card is issued to. Cards are bound to a single cardholder, and the cardholder must have `kycStatus: APPROVED` before a card can be issued — otherwise `POST /cards` is rejected with `CARDHOLDER_KYC_NOT_APPROVED`. ## Funding source The `InternalAccount` bound to a card. Every card has one funding source, and Authorization Decisioning checks its balance at auth time before approving a transaction. The `fundingSource` field on the `Card` resource identifies this account. ## Authorization The real-time request from the card network ("can this card spend \$12.50 at Blue Bottle Coffee?"). Grid runs Authorization Decisioning against the funding source and either approves the auth (placing a hold) or declines it, creating a terminal card transaction with `status: "DECLINED"` and a `cardDeclinedReason`. ## Pull A debit against the bound internal account that funds an approved authorization. Most transactions have a single pull at auth time. A restaurant tip or any settlement larger than the original auth produces a second, post-hoc pull — this is the over-auth path. ## Clearing (settlement) The network's confirmation that funds have moved for an authorization. A transaction can have multiple clearings (e.g. split shipments) and goes through `AUTHORIZED → PARTIALLY_SETTLED → SETTLED` as clearings land. ## Void A full authorization reversal or an authorization expiry before any clearing closes the hold without moving money. The card transaction is terminal with `status: "VOIDED"`. `authorizedAmount` reports what is still held, normally 0, and `settledAmount` is absent. ## Refund A merchant-initiated `RETURN` against a settled transaction. Refunds flow back to the funding source. A refund against a known purchase is its own dated `CardTransaction` row (`direction: CREDIT`) linked to the purchase via `originalTransactionId`; the purchase keeps its `SETTLED` status. ## Exception A transaction that settled to the card network but whose corresponding pull from the funding source failed — for example, the cardholder's balance no longer covers a post-hoc tip clearing. Exceptions are the high-urgency reconciliation alerts and are surfaced as `status: "EXCEPTION"` on the parent `CardTransaction`. ## PAN reveal A short-lived credential minted by `POST /cards/{id}/reveal` whose `panEmbedUrl` is the issuer's iframe URL that renders the full PAN, CVV, and expiry directly to the cardholder. Render it in an iframe in your client; the full credentials never cross Grid's servers. The URL expires at the reveal's `expiresAt` — request a fresh reveal right before rendering the iframe rather than caching the URL. # Real-time authorization Source: https://agents-docs.lightspark.com/cards/transactions/real-time-authorization Approve or decline card authorizations from your own endpoint By default, Grid decides every card authorization with its own checks. With real-time authorization, Grid also sends your `webhookEndpoint` a decision request for each authorization its checks approve, and your response decides whether the authorization proceeds. Use it to apply your own fraud rules, merchant restrictions, or velocity limits. Real-time authorization covers cards whose authorization decisions Grid makes: those whose `cardCapabilities.supportsSpendLimitsAtIssuance` is true. It never loosens Grid's own checks. ## How it works 1. A cardholder uses the card, and the card network asks Grid to authorize it. 2. Grid runs its checks: card status, card and platform spend limits, merchant category rules, and network support. If any check declines, Grid declines the authorization and does not send a decision request. 3. Grid sends your `webhookEndpoint` a signed `CARD_AUTHORIZATION.REQUEST`. 4. Your handler returns `APPROVE` or `DECLINE` within 1.5 seconds. 5. If you approve, Grid reserves the amount from the funding source. If the funding source cannot cover it, Grid declines with `INSUFFICIENT_FUNDS`. 6. Grid sends the final outcome to your `webhookEndpoint` as `CARD_TRANSACTION.AUTHORIZED` or `CARD_TRANSACTION.DECLINED`. Balance inquiries are never sent for a decision. ## Turn it on Set `cardConfigs.realTimeAuthorization` with `PATCH /platform/config`. ```bash cURL theme={null} curl -X PATCH "$GRID_BASE_URL/platform/config" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "cardConfigs": { "realTimeAuthorization": { "timeoutBehavior": "DECLINE" } } }' ``` Deploy a `webhookEndpoint` handler that answers `CARD_AUTHORIZATION.REQUEST` with a decision before you turn it on. A handler that returns an empty `200` is not a valid decision, so Grid applies `timeoutBehavior` to every authorization. To stop, set `cardConfigs.realTimeAuthorization` to `null`. Grid's own checks then decide every authorization. ## Test in sandbox In sandbox, your `webhookEndpoint` receives the requests that the `POST /sandbox/cards/{id}/simulate/authorization` simulator produces, as long as Grid's own checks approve them. To test `timeoutBehavior`, have your handler return a non-`200` status or wait longer than 1.5 seconds. ## Respond to requests Each request uses the standard webhook envelope and carries the authorization in `data`: ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000050", "type": "CARD_AUTHORIZATION.REQUEST", "timestamp": "2026-05-09T10:00:00Z", "data": { "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000100", "cardId": "Card:019542f5-b3e7-1d02-0000-000000000010", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "platformCustomerId": "18d3e5f7b4a9c2", "issuerTransactionToken": "lithic_txn_b81c2a4f", "transactionType": "AUTHORIZATION", "requestedAmount": { "amount": 12550, "currency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 } }, "approvedAmount": { "amount": 12550, "currency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 } }, "merchant": { "descriptor": "BLUE BOTTLE COFFEE SF", "mcc": "5814", "country": "US" }, "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" } } ``` Verify the `X-Grid-Signature` header as you do for other webhooks, and reject requests whose `timestamp` is more than a few minutes old. Then return HTTP `200` with your decision: ```json theme={null} { "decision": "APPROVE" } ``` ```json theme={null} { "decision": "DECLINE", "declineReason": "UNAUTHORIZED_MERCHANT" } ``` Grid passes `declineReason` to the card network, so choose the most specific one: | Reason | Use when | | - | - | | `SUSPECTED_FRAUD` | The authorization breaks your fraud rules, and no other reason fits. | | `UNAUTHORIZED_MERCHANT` | The merchant, merchant category, country, or transaction type is not allowed for this card. | | `VELOCITY_EXCEEDED` | The authorization exceeds a velocity limit you set. | | `INSUFFICIENT_FUNDS` | The cardholder lacks funds by your own accounting. | | `CARD_PAUSED` | You track a paused state for the card that its Grid `status` does not reflect. To pause a card everywhere, freeze it instead. | A decline you return reaches your `webhookEndpoint` as `CARD_TRANSACTION.DECLINED` with `cardDeclinedReason: PLATFORM_DECLINED` and your `declineReason` in `platformDeclineReason`. `approvedAmount` is the amount Grid authorizes if you approve. It becomes `authorizedAmount` on the resulting `CardTransaction`. ## Timeouts and errors Respond within 1.5 seconds. The card network holds the cardholder at the terminal while Grid waits, so keep the decision path fast and avoid calls to slow dependencies. If your handler times out, returns a status other than `200`, or returns an invalid body, Grid applies `cardConfigs.realTimeAuthorization.timeoutBehavior`: | `timeoutBehavior` | Result | | - | - | | `DECLINE` (default) | Grid declines with `cardDeclinedReason: PLATFORM_UNAVAILABLE`. | | `APPROVE` | Grid proceeds as though you approved. Its funding check still applies. | Grid does not retry an authorization request. ## Duplicate requests The card network can retry an authorization, so you can receive the same authorization more than once. Each delivery has a new `id`, but `data.transactionId` and `data.issuerTransactionToken` stay the same. Return the same decision each time. # Reconciliation Source: https://agents-docs.lightspark.com/cards/transactions/reconciliation How card transactions reconcile, and what exceptions to act on A card transaction is not a single event — it's a parent row plus a stream of child events from the card network. This page covers the event model, the status transitions, and how to handle the `EXCEPTION` path. ## The event model For each card authorization, Grid produces: 1. **One `CardTransaction` per purchase** — created at auth time, persists for the life of the transaction. 2. **Pulls** — debits against the funding source that fund approved auths and any post-hoc settlements. 3. **Clearings** — the network's confirmation that funds have moved. 4. **Refunds** — merchant-initiated `RETURN` events. A return against a known purchase opens its own dated `CardTransaction` row (`direction: CREDIT`) linked to the purchase via `originalTransactionId`, so statements can list it as its own line. Clearings are reconciled against the purchase and rolled up into its `settledAmount` total; the returned value lives on the refund row. ## Status transitions ```text theme={null} AUTH REQUEST ──┬──► DECLINED └──► AUTHORIZED ──┬──► VOIDED └──► CLEARING ──┬──► PARTIALLY_SETTLED ──► SETTLED └──► EXCEPTION ``` A merchant `RETURN` does not move the purchase's status. The purchase stays `SETTLED`; the return posts as its own `CREDIT` transaction. | Status | Meaning | | - | - | | `AUTHORIZED` | Auth approved, hold placed, no clearings yet. | | `PARTIALLY_SETTLED` | At least one clearing landed, but more are still expected (split shipments, multi-leg trips). | | `SETTLED` | All clearings for the auth have posted. The transaction is closed against the funding source. A `RETURN` received afterwards keeps it `SETTLED`; the return appears as its own `CREDIT` transaction. | | `DECLINED` | The authorization was declined before any money moved. Declines must be excluded from cardholder statements. | | `VOIDED` | The authorization was fully reversed or expired before any clearing posted. The hold closed without moving money, `authorizedAmount` reports what is still held (normally 0), and `settledAmount` is absent. | | `EXCEPTION` | The transaction settled to the network but the corresponding pull from the funding source failed. | Every transition is delivered as a `CARD_TRANSACTION.*` webhook whose `data` is the whole post-change `CardTransaction` — see [Webhooks](/cards/platform-tools/webhooks). ## The over-auth path The most common non-trivial flow is the over-auth (e.g. restaurant tip). The auth comes in at $12.50, but the merchant clears for $15.00. 1. Auth approved → one pull for \$12.50 → parent is `AUTHORIZED`. 2. Clearing for $15.00 → second post-hoc pull for $2.50 → parent is `SETTLED` with `settledAmount: 1500`. The post-settlement parent carries `authorizedAmount: 1250` and `settledAmount: 1500`. ## The EXCEPTION path An exception happens when the card network has already moved funds for a settlement but Grid can't pull the matching amount from the funding source — typically because the cardholder's balance no longer covers the post-hoc difference. Signal to watch: a transaction webhook with `status: "EXCEPTION"` for a card-destination transaction. The payload includes the full parent record, so your dashboard's exception view is driven entirely by webhook deliveries — there's no list endpoint to poll. Exceptions don't roll back automatically. The standard response is to top up the funding source (or move the customer to a state where their balance can be collected) and [contact our team](https://www.lightspark.com/contact) to resolve the exception. ## Idempotency on webhooks Every transaction webhook carries a unique `id`. Track processed webhook IDs and treat duplicates as no-ops — Grid retries failed deliveries, and your reconciliation should be safe under at-least-once delivery. See [Webhooks](/cards/platform-tools/webhooks) for signature verification and the full payload shape. # Changelog Source: https://agents-docs.lightspark.com/changelog New features, improvements, and updates to the Grid API. Follow along with the latest additions and improvements to the Grid API. For upcoming changes and roadmap, [contact our team](https://www.lightspark.com/contact). **November 2026** ## Issue a business's card to a named person `POST /cards` takes an optional `cardholder` for a `BUSINESS` customer: the person the card is issued to, with their name, email, phone number and US residential address. The card spends from the business's account and carries that person's name and contact details, so one business can hold cards for many people. Cards with the same `cardholder.reference` go to the same person. Omit `cardholder` to issue the card to the business itself, as before. **October 2026** ## Verify a new sign-in email or phone number before it replaces the old one Changing a Global Account customer's `email` or `phoneNumber` on `PATCH /customers/{customerId}`, when the account signs in with it, now needs a verified replacement. Register the new address with `POST /auth/credentials`, verify its code with `POST /auth/credentials/{id}/verify`, then send the customer update and its signed retry. The signed retry can return `200` with `{ "status": "PROCESSING" }` while the change lands, and `409 OPERATION_IN_PROGRESS` while another operation on that contact is in progress. A change without a verified replacement returns `400 AUTH_CREDENTIAL_VERIFICATION_REQUIRED` instead of a signed-retry challenge. The `EMAIL_OTP_CREDENTIAL_SET_CHANGED`, `SMS_OTP_CREDENTIAL_SET_CHANGED`, `EMAIL_OTP_CREDENTIAL_SYNC_FAILED`, and `SMS_OTP_CREDENTIAL_SYNC_FAILED` codes are retired. See [Changing the sign-in email or phone number](/global-accounts/authentication#changing-the-sign-in-email-or-phone-number). ## Sandbox suffix for a paused corridor In sandbox, an external account whose account number ends in `014` simulates a corridor paused for maintenance. The account lookup and the quote fail with `403 CORRIDOR_UNDER_MAINTENANCE`. Other accounts in the same currency work as normal, so you can test the paused and the open case side by side. See [sandbox testing](/payouts-and-b2b/platform-tools/sandbox-testing#testing-cross-currency-quotes). ## Sandbox no longer accepts placeholder signatures The sandbox placeholders `Grid-Wallet-Signature: sandbox-valid-signature` and the passkey assertion signature `sandbox-valid-passkey-signature`, deprecated earlier this month, are no longer accepted. Sandbox rejects each as a malformed stamp or assertion with `401 UNAUTHORIZED`, as production always has. Send a real API-key stamp over `payloadToSign` on every signed action and the real WebAuthn assertion on passkey verify. See [Sign a `payloadToSign`](/global-accounts/client-keys#2-sign-a-payloadtosign) and [sandbox testing](/global-accounts/platform-tools/sandbox-testing). ## ACH `railDetails.returnCode` and `returnCategory` A returned ACH entry's `railDetails` now carries `returnCode`, the NACHA return reason code the receiving bank sent it back under, such as `R01` (insufficient funds) or `R10` (customer advises unauthorized). It is present with `returnedAt` on returned payouts and on returned pulls from your customers' bank accounts. Group returns by `returnCode` to track your return rate for each reason. NACHA may add codes, so accept any value of the form `R` followed by two digits. `railDetails.returnCategory` names the Nacha return-rate category the code counts toward: `ADMINISTRATIVE` (`R02`, `R03`, `R04`) or `UNAUTHORIZED` (`R05`, `R07`, `R10`, `R11`, `R29`, `R51`). Nacha sets a separate threshold for each. A code in neither, such as `R01`, has no `returnCategory`. ## `EXTERNAL_ACCOUNT.ACCOUNT_INFO_UPDATED` webhook Grid now tells you when it corrects an external account's details. When the receiving bank returns an ACH notification of change (NOC), Grid applies the corrected routing number, account number or bank account type and sends `EXTERNAL_ACCOUNT.ACCOUNT_INFO_UPDATED`. The payload is the full external account with the corrected `accountInfo`. The account keeps its `id`, and later payments use the corrected details. Deliveries can arrive out of order, so fetch the account before you replace any copy of its details you store. ## Two new `cardDeclinedReason` values A declined card transaction's `cardDeclinedReason` now names two more causes that were reported as `OTHER`: * `INCORRECT_CARD_DETAILS`: the PIN, security code, expiry date, or billing address entered at the merchant did not match the card. * `TEMPORARILY_UNAVAILABLE`: a temporary problem at Grid, the card processor, or the card network stopped the authorization. The cardholder can try again. Production already declines an authorization the funding source cannot cover, but has reported it as `OTHER`. It now reports `INSUFFICIENT_FUNDS`, and the reference no longer calls that value sandbox-only. `OTHER` now covers only the remaining reasons, including a security or fraud decline the card processor made. Transactions declined before this change keep their reason. > [!WARNING] > > If you validate `cardDeclinedReason` against a fixed list, add the two new values, and > treat any value you don't recognize as `OTHER`. ## Sandbox values for each payout `failureReason` You can now test each specific payout `failureReason` in sandbox. Pay out to an external account whose number ends in one of these suffixes, or to the matching UMA address: | Suffix | UMA address | `failureReason` | | - | - | - | | `010` | `$fail.payout.compliance.usd@sandbox.uma.money` | `COMPLIANCE_REJECTED` | | `011` | `$fail.payout.accountinvalid.usd@sandbox.uma.money` | `ACCOUNT_INVALID` | | `012` | `$fail.payout.cannotreceive.usd@sandbox.uma.money` | `ACCOUNT_CANNOT_RECEIVE` | | `013` | `$fail.payout.limitexceeded.usd@sandbox.uma.money` | `LIMIT_EXCEEDED` | The existing suffix `005` completes the payout, then fails it with `PAYOUT_RETURNED`. ## Set the ACH statement description with `statementDescriptor` `POST /quotes` takes a new optional `statementDescriptor`: up to 10 characters that label the payment on the counterparty's bank statement, such as `PAYROLL`. It is used only on ACH, where it is sent as the NACHA Company Entry Description, for both a payout to an external US bank account and a pull from one: ```json theme={null} { "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123", "paymentRail": "ACH" }, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 10000, "statementDescriptor": "PAYROLL", "remittanceInformation": "INV-12345" } ``` Grid uses its default when you omit it. Sending it on a quote that moves no ACH entry returns `400 INVALID_INPUT`. Longer payment details still belong in `remittanceInformation`, which ACH carries in the addenda record. ACH `railDetails` on transactions and webhooks reports the value as `statementDescriptor`. The `remittanceInformation` description now also covers rails beyond ACH, RTP, FedNow, and wire. On rails such as SEPA, PIX, SPEI, and Faster Payments it is sent as the payment reference on the recipient's bank statement, which is often much shorter than 80 characters, so put the most important information first. ## List balance changes for periodic statements `GET /internal-accounts/{id}/balance-changes` returns every change to an internal account's balance between `startDate` and `endDate`, in the order the money moved. The window is half-open, so consecutive windows leave no gap and do not overlap. The response also carries the window's `openingBalance` and `closingBalance`, and the opening balance plus the sum of every `amount` across all pages equals the closing balance. Each change has an `id`, `amount`, `effectiveAt`, and an optional `transactionId` that you can fetch from `GET /transactions/{transactionId}`. Fees are already inside `amount`; a transaction's total fee is `fees` on the transaction. A window whose card settlement has not closed returns `409 NOT_YET_AVAILABLE`. Retry once it has settled. To build a statement from these changes, see the guides for [cards](/cards/statements) and [payouts](/payouts-and-b2b/payment-flow/statements). ## Business customers can omit `taxId` and `incorporatedOn` `businessInfo.taxId` and `businessInfo.incorporatedOn` are no longer required in the schema when you create a business customer. They are still required when the customer's currency uses direct customer-owned accounts, and Grid returns `400 INVALID_INPUT` naming the missing field. When you fund customers from an omnibus FBO account, you can omit them. For details, see [configuring customers](/ramps/onboarding/configuring-customers). ## API tokens can expire `ApiToken` has a new optional `expiresAt`. When it is present, requests made with that token after that time return `401`, and you need to create a new token. Tokens without `expiresAt` do not expire. ## A UMA address destination requires `currency` `UmaAddressDestination.currency` on `POST /quotes` is now marked required in the spec. The API has always rejected a UMA address destination without it, so no working integration changes. Generated clients now enforce it at build time. ## Quote and transfer creation answer `200` `POST /quotes`, `POST /transfer-in` and `POST /transfer-out` have always answered `200 OK` for a newly created quote or transfer, and the spec now says so instead of `201 Created`. The `202 Accepted` reply for a quote awaiting Strong Customer Authentication is unchanged. ## `railDetails` on transaction sources and destinations Account and external-funding sources, and account destinations, carry a `railDetails` describing the rail transfer that moved funds for that side. It is a oneOf discriminated by `paymentRail`, and each rail has its own schema, status values, and bank account fields. It covers the rails below; a side that moved over any other rail has no `railDetails`, and the deprecated fields remain its source of rail details. | `paymentRail` | Schema | `status` values | Rail-specific fields | | - | - | - | - | | `ONCHAIN` | `OnChainRailDetails` | — | `transactionHash`, `network` | | `ACH`, `ACH_SAME_DAY` | `AchRailDetails` | `PENDING`, `SETTLED`, `RETURNED`, `RETURN_DISHONORED`, `RETURN_CONTESTED`, `REVERSED`, `FAILED` | `traceNumber`, `addenda`, `returnedAt`, `dishonoredAt`, `contestedAt`, `reversedAt` | | `WIRE` | `WireRailDetails` | `PENDING`, `SETTLED`, `FAILED` | `imad`, `originatorToBeneficiaryInformation` | | `FEDNOW` | `FedNowRailDetails` | `PENDING`, `SETTLED`, `FAILED` | `endToEndId`, `remittanceInformation` | | `RTP` | `RtpRailDetails` | `PENDING`, `SETTLED`, `FAILED` | `endToEndId`, `remittanceInformation` | `railDetails.status` reports the transfer on its rail; the transaction's top-level `status` reports where the funds are now, and its webhook events are unchanged. A returned ACH payout still moves to `FAILED` with `PAYOUT_RETURNED` and is refunded as before; its destination's `railDetails` now also shows `RETURNED` with `returnedAt`. An ACH deposit returned after `COMPLETED` moves to `FAILED` and sends `INCOMING_PAYMENT.FAILED`, with the source's `railDetails` showing `RETURNED`. A transaction stays `COMPLETED` through a return only when a later step that can't be undone already used the funds. `FAILED` is no longer described as terminal. On an incoming payment, a dishonored return restores the credit and moves the transaction back to `COMPLETED`. A returned payout stays `FAILED`, since it has already been refunded. Either way the ACH `railDetails` records the step: `RETURN_DISHONORED` with `dishonoredAt`, and `RETURN_CONTESTED` with `contestedAt` if the dishonor is contested. Only `EXPIRED` is terminal; apply each status webhook as it arrives. `TransactionStatus.REFUNDED` is deprecated: a refund is reported on the transaction's `refund` object. The flat originator fields on the external funding source (`accountHolderName`, `accountIdentifier`, `bankName`, `bankIdentifier`, `paymentRail`, `remittanceInformation`, `endToEndId`, `traceNumber`) are deprecated, as is `onChainTransaction`. They remain populated alongside `railDetails`, and on rails without a `railDetails` variant, such as SWIFT, they are the only source of the originator's details. **September 2026** ## Counterparty failures a retry can fix return `5xx` When a lookup or quote fails because of the counterparty and a retry could succeed, Grid now returns a `5xx` status. Some of these failures returned `400`, which reads as "your request was wrong". Existing error codes keep their names. Only their HTTP status moves. * `LOOKUP_REQUEST_FAILED` returns `504` instead of `400`. A network error or timeout stopped a receiver lookup from reaching the counterparty. * `QUOTE_REQUEST_FAILED` returns `504` instead of `400`. A network error or timeout stopped `POST /quotes` from sending the quote request to the counterparty. * `INVALID_PAYREQ_RESPONSE` returns `502` instead of `400`. The counterparty returned an invalid payment request. * The new `COUNTERPARTY_SERVER_ERROR` returns `502`. The counterparty answered a lookup or quote request with a server error. This includes a quote to a receiver whose provider is temporarily unavailable, which returned `400 INVALID_INPUT`. A lookup for a UMA address whose domain doesn't exist is a bad address, not a retryable failure. It now returns `404 USER_NOT_FOUND` instead of `400 LOOKUP_REQUEST_FAILED`. If you already retry on `5xx`, these are retried with no code-specific handling. If you detect them by matching `400` or `424`, match on `code` instead. See [Error handling](/payouts-and-b2b/payment-flow/error-handling). ## Sandbox test suffixes work on Solana addresses A sandbox external account's last three characters pick the test outcome, and every failure suffix starts with `00`. Base58 has no `0`, so no valid Solana address could end in one. The transfer and quote destination suffixes `002`–`008` now have `11X` twins: an address ending in `112` behaves like `002`, and `117` like `007`. The `00X` forms still work. See [Sandbox testing](/api-reference/sandbox-testing). ## `customerId` is required when creating a customer external account `POST /customers/external-accounts` has always rejected a request that omits `customerId`, and the spec now says so. Its request body is its own schema, `CustomerExternalAccountCreateRequest`, in which `customerId` is required. Use `POST /platform/external-accounts` to create an external account owned by the platform itself. ## Same-day ACH is its own payment rail `ACH_SAME_DAY` joins `ACH` in `PaymentRail`. Pin it on a quote's destination to request same-business-day settlement for a USD payout. * Grid never selects `ACH_SAME_DAY` for you. It is only used when you name it, so existing integrations that omit `paymentRail` are unaffected. * The two rails are priced separately. A `RailFeeConfig` with `rail: ACH_SAME_DAY` prices same-day sends; your existing `ACH` config keeps pricing standard sends. * `ACH_SAME_DAY` is capped at $1,000,000 per entry by the NACHA same-day limit, rising to $10,000,000 on 2027-09-17. A payout above the limit is rejected rather than slowed; send it over `ACH` instead. * `ACH` will settle on the standard next-business-day schedule. Until a date we announce in advance, `ACH` continues to settle same-business-day on production platforms; it already settles next-business-day on sandbox platforms. To guarantee same-day settlement after that date, request `ACH_SAME_DAY`. See [Assessing fees](/payouts-and-b2b/payment-flow/assessing-fees). ## Add cards to Apple Pay, Google Pay, and Samsung Pay from your app New `POST /cards/{id}/tokenize` exchanges the values a wallet SDK produces for the encrypted provisioning payload that completes an in-app "Add to Wallet" tap. The payload is encrypted to the wallet provider's keys, so card data never crosses your servers. `Card.cardCapabilities` gains `supportsDigitalWalletTokenization`. Manual entry into a wallet continues to work for every card with no integration. See [Digital wallet tokenization](/cards/card-management/digital-wallet-tokenization). ## Card refunds, declines, and voids report distinct outcomes Three card-statement corrections to `CardTransaction`: * **Refunds are their own dated rows.** A merchant `RETURN` against a purchase now opens its own `CardTransaction` (`direction: CREDIT`, credited value in `settledAmount`) linked back to the purchase via the new `originalTransactionId` field, instead of folding into the purchase's `refundedAmount`. The purchase stays `SETTLED`; the refund fires its own `CARD_TRANSACTION.*` webhooks on the refund row. To reverse a sandbox return, pass the refund row's id to `POST /sandbox/cards/{id}/simulate/return_reversal`. * **`DECLINED` status with `cardDeclinedReason`.** An authorization declined before any money moved now reports `DECLINED` (and fires `CARD_TRANSACTION.DECLINED`) instead of `EXCEPTION`, which is reserved for settlement anomalies. The new `cardDeclinedReason` field explains why Grid declined the authorization: `CARD_NOT_ACTIVE` (frozen or closed card), `SPEND_LIMIT_EXCEEDED`, `INSUFFICIENT_FUNDS` (sandbox only—production approves underfunded auths and resolves them as `EXCEPTION`), `NO_ELIGIBLE_FUNDING_SOURCE` (e.g., Embedded Wallet with no delegated key), `BLOCKED` (merchant category or transaction blocked), `UNSUPPORTED_NETWORK`, or `OTHER`. Declines must be excluded from cardholder statements. * **`VOIDED` status.** An approved authorization that is fully reversed or expires before any clearing posts now reports `VOIDED` (and fires `CARD_TRANSACTION.VOIDED`) instead of `SETTLED`. * Card rows now carry the same full currency metadata (`decimals`, `name`, `symbol`) as payment rows, and `merchant` gains `city` and `state` when the network reports them. * `GET /transactions` accepts a `cardId` filter (`Card:` LSID or UUID) for listing one card of a multi-card cardholder, and rejects `status` combined with `type=CARD` with a 400—`status` applies to payment transactions only. ## Record consent for each agreement by type `agreementConsents` supersedes `endUserTermsConsent`: a list with one entry per agreement, so a customer's acceptance of each document is recorded and auditable separately. * Each entry carries a `type` alongside the existing `acceptedAt`, `ipAddress`, `termsVersion`, and `acceptanceMethod` fields. Send at most one entry per type. * `GET /customers/agreements` lists every supported agreement with its `type`, current `version`, and hosted `url`. Send an entry's `version` as `termsVersion` when recording that agreement's acceptance. `GET /customers/end-user-terms` is deprecated but unchanged — it still returns the single `{ version, url }` object, so existing callers keep working. * Customer responses return `agreementConsents`, holding the most recent acceptance per accepted type, and an empty list until the first acceptance. * On update, supplying consents records additional acceptances; acceptances already on file for other types are left untouched. `endUserTermsConsent` is **deprecated, not removed** — it still works on both the request and response, and is equivalent to a single `LIGHTSPARK_END_USER_TERMS` entry. Acceptance you have already recorded is migrated for you and reported under that type; you don't need to re-collect it. Sending both fields in one request is rejected. Accepting one agreement never implies acceptance of another, so the remaining six types must be collected from each customer. See [Disclosures](/payouts-and-b2b/onboarding/disclosures). ## Card returns report as `SETTLED` with a `refundedAmount` `REFUNDED` is removed from `CardTransactionStatus`, and `CARD_TRANSACTION.REFUNDED` from the webhook types. A card transaction's status tracks authorization outcomes and settlement—a return is reported through `direction` and `refundedAmount`, which together already carry it. * A settled purchase that the merchant later returns stays `SETTLED`, with the returned value in `refundedAmount`. A standalone merchant refund arrives as `direction: CREDIT` and `status: SETTLED`, with the credited value in `settledAmount`. * Returns no longer get a webhook type of their own—the transaction re-fires `CARD_TRANSACTION.SETTLED` with the updated amounts. * The sandbox return reversal simulator requires a parent with a posted return (non-zero `refundedAmount`) rather than a `REFUNDED` parent, which the previous docs described and the server never produced. Nothing you have received changes: the status was always derived on read, so existing transactions already report `SETTLED`. `TransactionStatus.REFUNDED` on cross-border payments is a separate enum and is unaffected. See [Reconciliation](/cards/transactions/reconciliation). ## Sandbox KYC and KYB follow the production flow Unregulated sandbox platforms now resolve a customer's verification from a submitted packet rather than at create, so your sandbox integration rehearses the same collect, submit, and resolve loop you run in production. > [!WARNING] > > Behavior change. On an unregulated sandbox, a name suffix alone no longer sets the > result at create. New customers stay `UNVERIFIED` until you call `POST /verifications`. > Customers created before this change keep their status, and regulated sandbox platforms > are unaffected. * Upload documents with `POST /documents`, register beneficial owners for a business, then submit with `POST /verifications`. The suffix on `fullName`, `registrationNumber`, or a beneficial owner's `lastName` still decides the outcome—it applies once the submission is in rather than at create. * An incomplete submission returns `verificationStatus: RESOLVE_ERRORS` naming every missing field and document, so you can fix and resubmit. A business whose `registrationNumber` ends in `002` now reports those errors first rather than rejecting outright. * Renaming a customer no longer overwrites a settled result—`APPROVED` and `REJECTED` stay put. Create a new customer when you want to exercise a different outcome. * To keep the previous fast path, open **Configuration** in your sandbox dashboard and turn on **Skip verification paperwork**. A terminal suffix then resolves with no documents: individuals at create, businesses at their first submission. `001` and `003` still require a complete packet. Turn it off when you want to test the full flow again. Walk through the fix-and-resubmit loop in [sandbox testing](/api-reference/sandbox-testing). **August 2026** ## `bankAccountType` is required on USD external accounts Creating an external account with `accountType: USD_ACCOUNT` now requires `bankAccountType`. Send `CHECKING` or `SAVINGS` on `POST /customers/external-accounts`, `POST /platform/external-accounts`, and `POST /agents/me/external-accounts`. * Grid uses `bankAccountType` to set the ACH transaction code. Accounts created without it were sent as checking, and the receiving bank returned a notification of change to correct savings accounts. * External accounts created before this change are unaffected. See [External accounts](/ramps/accounts/external-accounts) for a full request body. ## `/transfer-in` and `/transfer-out` are deprecated Same-currency transfers now go through the quote endpoint, so one integration covers same-currency and cross-currency alike. * Use `POST /quotes` with `immediatelyExecute: true` to create and execute a same-currency transfer in a single request. * `amount` becomes `lockedCurrencyAmount` with `lockedCurrencySide: "SENDING"`; source and destination gain `sourceType: "ACCOUNT"` and `destinationType: "ACCOUNT"`. `remittanceInformation`, `purposeOfPayment`, and the destination `paymentRail` carry over unchanged. * The response is a `Quote` rather than a `Transaction`—read `transactionId` from it to track the resulting transaction. * `POST /transfer-in` and `POST /transfer-out` continue to work with unchanged request and response shapes. See [Send a payment](/payouts-and-b2b/payment-flow/send-payment#send-a-payment) for the updated flow. ## Assess your own fees on every transaction Charge your customers a platform fee and keep the margin—Grid collects it for you and reports it separately from network and FX costs. * Set a standing fee with `feeConfigs` on `PATCH /config`—a variable rate in basis points, a fixed amount, or both, applied to cross-currency transactions. * Override it on a single transaction with `platformFeeOverride` on `POST /quotes`, for promos, VIP pricing, or negotiated rates. No standing config required. * Both the standing config and the per-transaction override require a USD source currency today. * Reconcile with `platformFeesIncluded` on quotes and `platformFees` on outgoing transactions—your cut, broken out of the total. See [Fees](/platform-overview/core-concepts/quote-system#fees) for exactly how the variable fee is assessed on each locked side. ## Record end-user terms acceptance Unregulated platforms must record that each customer accepted Grid's end-user terms before their account opens. * `GET /customers/end-user-terms` returns the current terms URL and version. * Pass `endUserTermsConsent` on customer create or update with the timestamp, IP address, terms version, and acceptance method. * Quotes fail with `END_USER_TERMS_NOT_ACCEPTED` until acceptance is on file. See [Disclosures](/payouts-and-b2b/onboarding/disclosures). ## Run KYC from your own onboarding form Collect verification data in your own UI and submit it programmatically instead of handing customers to a hosted flow. * Enhanced due diligence—source of funds and wealth, purpose of account, expected transaction count and volume, income and net worth ranges, PEP status—is now a set of optional fields on the individual customer itself. Send them on `POST /customers` or add them later with `PATCH /customers/{customerId}`; there is no separate EDD resource. * `POST /verifications` returns `RESOLVE_ERRORS` with one entry per problem, each naming the exact field or accepted document types still needed, so you can fix and resubmit without guessing. * Request validation errors return every invalid field at once in `Error400.details.errors[]`, each with a machine-readable constraint you can render as field-level UX—no more resubmitting to discover the next error. * Individual customers and beneficial owners now share one identification vocabulary: `idType`, `identifier`, and `countryOfIssuance`. Rehearse the fix-and-resubmit loop in [sandbox testing](/api-reference/sandbox-testing). ## Groundwork for EU support: SCA and Travel Rule The API surface for the two controls EU regulation requires is now defined, so you can design against it ahead of EU corridors opening. * **Strong Customer Authentication**—`POST /sca/login/complete` returns `sessionExpiresAt`, so you can prompt a re-login before the 180-day session lapses rather than discovering it as a failed payment. Quote authorization documents `SCA_SESSION_REQUIRED` (409) and `ACCOUNT_LOCKED` (423), and `SCA_NOT_COMPLETED` explains a transaction that failed on an expired challenge. * **Travel Rule ownership verification**—a challenge and verify flow for proving a customer owns a self-custody wallet. `POST …/external-accounts/{id}/challenge` starts verification by wallet signature or hosted liveness check, and `…/verify` completes a signature synchronously. Accounts sit at `PENDING_OWNERSHIP_VERIFICATION`—still usable below regulatory thresholds—and move to `ACTIVE` on success or `UNVERIFIED` on a failed attempt. A single webhook, `EXTERNAL_ACCOUNT.STATUS_UPDATED`, covers the whole lifecycle. EU availability will be announced separately. ## Issue your own stablecoin Register a stablecoin with `/stablecoins`, then mint and burn directly against it with `/stablecoins/{stablecoinId}/mints` and `/stablecoins/{stablecoinId}/burns`. `/stablecoins/{stablecoinId}/operations` tracks each issuance through settlement. ## More ways to move money * **USDT on Ethereum and Plasma**, alongside Tron. * **Bitcoin L1** deposit addresses as a payment instruction on quotes. * USD accounts can describe a full wire beneficiary—bank name, checking or savings, intermediary bank and routing number, and bank-to-bank instructions. * Businesses outside the US can register as a publicly listed company, trust, private foundation, or charity. See [Currencies and rails](/platform-overview/core-concepts/currencies-and-rails). ## Track crypto settlement to and from external wallets A transaction that settles on-chain to or from an external crypto wallet now carries the settled transfer inline: `onChainTransaction` on the transaction's source or destination reports the transfer's `transactionHash` and `network` once the crypto transfer settles. > [!WARNING] > > Deprecation. `reconciliationInstructions.transactionHash` is deprecated for wallet > transfers—read `onChainTransaction` instead, which also tells you the network. The field > keeps reporting the inter-VASP settlement leg of a UMA payment, which has no > `onChainTransaction` equivalent, and will not be removed before that leg has a > replacement. ## Know why a payment failed Failure reasons now name outcomes you act on rather than internal processing steps. > [!WARNING] > > Breaking change. `EXECUTION_FAILED_POST_DEBIT` and `SETTLEMENT_FAILED` are removed and > collapse into `QUOTE_EXECUTION_FAILED`, whose description now covers the whole path to > settlement and states that a debited amount is refunded automatically. `TIMEOUT` and > `MANUAL_REFUND` are removed because that outcome already surfaces on the refund object. * New payout failure reasons: `PAYOUT_RETURNED`, `LIMIT_EXCEEDED`, `ACCOUNT_CANNOT_RECEIVE`, `ACCOUNT_INVALID`, and `COMPLIANCE_REJECTED`. * `pendingReason` on any transaction tells you when it is held for compliance review or waiting on customer action. * New limit codes: `TRANSACTION_SIZE_LIMIT_EXCEEDED` (400) and `DAILY_VOLUME_LIMIT_EXCEEDED` (429). See [Transaction lifecycle](/platform-overview/core-concepts/transaction-lifecycle#failure-handling). **July 2026** ## Card issuance in sandbox You can now issue cards directly in the sandbox environment. Test the full card lifecycle—[cardholder setup](/cards/onboarding/cardholder-setup), [issuing cards](/cards/card-management/issuing-cards), [funding sources](/cards/card-management/funding-sources), and [freezing and closing](/cards/card-management/freezing-and-closing)—end to end before going live, without touching production. See [Cards sandbox testing](/cards/platform-tools/sandbox-testing) to get started. ## Cards: real-time webhooks and full event simulation React to card activity as it happens, and rehearse every event before going live. * `CARD_TRANSACTION.*` fires on every state transition—authorized, partially settled, settled, refunded, and exception—carrying the full card transaction. * All ten sandbox simulate endpoints, covering balance inquiries, credit and financial authorizations, authorization advices, returns, and return reversals. * Brand the tokenization verification codes your customers receive with `cardTokenization2faConfig` on platform config. See [Card webhooks](/cards/platform-tools/webhooks). ## Refund visibility on incoming payments `INCOMING_PAYMENT.REFUND_PENDING`, `INCOMING_PAYMENT.REFUND_COMPLETED`, and `INCOMING_PAYMENT.REFUND_FAILED` now fire the same way outgoing refunds already did, so a returned deposit no longer surfaces only as a failed transaction. See [Refund object](/platform-overview/core-concepts/transaction-lifecycle#refund-object). ## Expanded country coverage Grid now supports additional countries, including **China**, broadening the corridors available for global payments. Review the currencies and rails available in each region in [Currencies and rails](/platform-overview/core-concepts/currencies-and-rails). # Playground Source: https://agents-docs.lightspark.com/demo Give an agent an account and watch the exact API calls fire as you go # Build your flow Source: https://agents-docs.lightspark.com/flow-builder Design your flow and get the API calls for your exact use case # Authentication Source: https://agents-docs.lightspark.com/global-accounts/authentication Register, reauthenticate, and manage Global Account credentials (passkey, OAuth, email OTP) Every Global Account action beyond receiving funds must be authorized by a session signing key. Sessions are issued by verifying one of four **credential types** on the account: | Type | When to use it | | - | - | | **`PASSKEY`** | Best default. Biometric, phishing-resistant, usable across the user's devices via iCloud Keychain / Google Password Manager. | | **`OAUTH`** | Your platform already authenticates the user via OIDC (Google, Apple, your own IdP) and you want Grid to trust the same identity. | | **`EMAIL_OTP`** | Lowest-friction option. Works on any device with email access — no biometric hardware, identity provider, or client SDK required beyond the code entry field. | | **`SMS_OTP`** | Same flow as `EMAIL_OTP`, but delivered to the user's phone number instead of email. Useful when phone is the primary contact channel. | A single internal account can hold one `EMAIL_OTP` credential, one `SMS_OTP` credential, and multiple distinct `PASSKEY` credentials concurrently. `OAUTH` credentials can be added for each supported provider identity. ## Registration vs. verification Global Accounts are initialized with an `EMAIL_OTP` credential tied to the customer email on the internal account. Use that credential to mint the first session, or use an already-registered `OAUTH` or `PASSKEY` credential after you add one. To produce a session: * **`EMAIL_OTP`** — if you need a fresh OTP or encryption target, first call **`POST /auth/credentials/{id}/challenge`**. Generate a P-256 client key pair, HPKE-encrypt the OTP value plus the public key into `encryptedOtpBundle`, then call **`POST /auth/credentials/{id}/verify`**. Grid returns a `202` signed-retry challenge; stamp its `payloadToSign` with the same private key and retry with `Grid-Wallet-Signature` plus `Request-Id`. The signed retry returns the session; the private key you generated is the session signing key. * **`OAUTH`** — call **`POST /auth/credentials/{id}/verify`** with a fresh OIDC token plus `clientPublicKey`. The response is the session; the private key matching `clientPublicKey` is the session signing key. * **`PASSKEY`** — call **`POST /auth/credentials/{id}/challenge`** with your `clientPublicKey` to receive a Grid-issued `challenge` and `requestId`, UTF-8 encode the challenge string for `navigator.credentials.get()`, then call **`POST /auth/credentials/{id}/verify`** with the resulting assertion and the `Request-Id` header. Grid bakes the `clientPublicKey` from the `/challenge` call into the session-creation payload, so it does **not** appear on the `/verify` body. To add another credential, call **`POST /auth/credentials`** with the new credential details. Because the account already has an `EMAIL_OTP` credential, Grid returns a `202` signed-retry challenge. Stamp that `payloadToSign` with an active session signing key, then retry the same request with `Grid-Wallet-Signature` and `Request-Id`. The signed retry returns the new `AuthMethod`. Re-authentication after a session expires skips the original `POST /auth/credentials` create call but otherwise follows the same per-type pattern. `EMAIL_OTP` re-auth needs a fresh OTP email, so call `POST /auth/credentials/{id}/challenge` first; `PASSKEY` re-auth uses the same `/challenge` (with `clientPublicKey`) → `/verify` two-step as the first authentication; `OAUTH` is the exception — since proof of control is a fresh OIDC token, there's nothing to pre-issue, so `/verify` alone suffices. ## Passkey ### Passkey registration Passkey registration spans four parties: the **client** (browser or app), your **integrator backend**, **Grid**, and the platform authenticator (Touch ID / Face ID / Windows Hello / a security key). Your backend issues the WebAuthn registration challenge. Registering the passkey on Grid is an additional-credential signed-retry flow, authorized by an active session from an existing credential. The first authentication challenge is requested explicitly via `POST /auth/credentials/{id}/challenge` — same call shape used for every subsequent reauthentication. ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid participant A as Authenticator C->>IB: Start registration IB->>IB: Generate random challenge (stored in session) IB-->>C: { challenge, rpId, user } C->>A: navigator.credentials.create({ challenge, … }) A-->>C: attestation (credentialId, clientDataJson, attestationObject) C->>IB: POST /my-backend/passkey/register (attestation) IB->>G: POST /auth/credentials { type: PASSKEY, challenge, attestation, … } G-->>IB: 202 { payloadToSign, requestId, expiresAt } IB-->>C: { payloadToSign, requestId } C->>C: stamp(payloadToSign, existingSessionPrivateKey) C->>IB: { stamp, requestId } IB->>G: Same POST
          Grid-Wallet-Signature: stamp
          Request-Id: requestId G-->>IB: 201 AuthMethod { id, type, accountId, … } IB-->>C: { credentialId } C->>C: generateClientKeyPair() C->>IB: POST /my-backend/passkey/challenge (clientPublicKey) IB->>G: POST /auth/credentials/{id}/challenge
          { clientPublicKey } G-->>IB: 200 PasskeyAuthChallenge { challenge, requestId, expiresAt } IB-->>C: { challenge, requestId } C->>A: navigator.credentials.get({ challenge, … }) A-->>C: assertion (credentialId, clientDataJson, authenticatorData, signature) C->>IB: POST /my-backend/passkey/verify (assertion, requestId) IB->>G: POST /auth/credentials/{id}/verify
          Request-Id: requestId
          { type: PASSKEY, assertion } G-->>IB: 200 AuthSession { id, expiresAt } IB-->>C: { sessionId, expiresAt } C->>C: hold the client private key as the session signing key ``` The `challenge` on `POST /auth/credentials` is the one your backend issued for WebAuthn registration. The signed-retry `payloadToSign` is the registration payload that the customer's current session authorizes. The challenge used for the WebAuthn assertion comes later from `POST /auth/credentials/{id}/challenge` — Grid bakes the `clientPublicKey` you send there into the session-creation payload, so the resulting session belongs to the key the device generated. Because that key plumbing happens on `/challenge`, the subsequent `/verify` call carries only the assertion (plus the `Request-Id` header). > [!TIP] > > Passkeys are domain-bound. Before shipping, set up your `/.well-known/apple-app-site-association` and `/.well-known/assetlinks.json` entries so the platform authenticator binds the passkey to your origin and app bundles: > > * Web: [passkeys.dev bootstrapping guide](https://passkeys.dev/docs/use-cases/bootstrapping/#opting-the-user-into-passkeys) > * Android: [Create passkeys on Android](https://developer.android.com/identity/passkeys/create-passkeys) > * iOS: [Supporting passkeys](https://developer.apple.com/documentation/authenticationservices/supporting-passkeys) #### Client sample code The client never talks to Grid. It talks to your integrator backend — the snippets below simulate that call with `fetch('/my-backend/...')`, which your backend then relays to Grid. ```typescript Web (TypeScript) theme={null} // 1. Ask your backend for a registration challenge. const startRes = await fetch("/my-backend/passkey/register/start", { method: "POST", credentials: "include", }); const { challenge, rpId, user } = await startRes.json(); // 2. Ask the authenticator to create a passkey. const attestation = (await navigator.credentials.create({ publicKey: { challenge: base64urlToBytes(challenge), rp: { id: rpId, name: "Acme Wallet" }, user: { id: base64urlToBytes(user.id), name: user.email, displayName: user.displayName, }, pubKeyCredParams: [{ type: "public-key", alg: -7 }], // ES256 authenticatorSelection: { residentKey: "required", userVerification: "required" }, timeout: 60_000, }, })) as PublicKeyCredential; const att = attestation.response as AuthenticatorAttestationResponse; // 3. Send the attestation to your backend, which starts POST /auth/credentials. const registerRes = await fetch("/my-backend/passkey/register", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nickname: "This device", credentialId: bytesToBase64url(new Uint8Array(attestation.rawId)), clientDataJson: bytesToBase64url(new Uint8Array(att.clientDataJSON)), attestationObject: bytesToBase64url(new Uint8Array(att.attestationObject)), transports: att.getTransports?.() ?? [], }), }); const { payloadToSign, requestId: registrationRequestId } = await registerRes.json(); // 4. Stamp the registration payload with an existing session signing key, then // complete the signed retry. The backend retries the same Grid request with // Grid-Wallet-Signature and Request-Id. const gridWalletSignature = await buildGridWalletSignature( existingSessionPrivateKeyBytes, payloadToSign, ); const registerCompleteRes = await fetch("/my-backend/passkey/register/complete", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requestId: registrationRequestId, gridWalletSignature }), }); const { credentialId } = await registerCompleteRes.json(); // 5. Generate the client key pair and ask Grid for an authentication challenge // bound to its public key. const { privateKey, publicKeyHex } = generateClientKeyPair(); const challengeRes = await fetch("/my-backend/passkey/challenge", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ credentialId, clientPublicKey: publicKeyHex }), }); const { challenge: gridChallenge, requestId: authRequestId } = await challengeRes.json(); // 6. Run the WebAuthn assertion against the Grid-issued challenge. This // challenge is a lowercase hex string; UTF-8 encode it exactly as returned. const assertion = (await navigator.credentials.get({ publicKey: { challenge: new TextEncoder().encode(gridChallenge), rpId, userVerification: "required", allowCredentials: [{ type: "public-key", id: base64urlToBytes(credentialId) }], }, })) as PublicKeyCredential; const asr = assertion.response as AuthenticatorAssertionResponse; // 7. Send the assertion to your backend; it relays to POST /verify with Request-Id. const verifyRes = await fetch("/my-backend/passkey/verify", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ credentialId, requestId: authRequestId, assertion: { credentialId: bytesToBase64url(new Uint8Array(assertion.rawId)), clientDataJson: bytesToBase64url(new Uint8Array(asr.clientDataJSON)), authenticatorData: bytesToBase64url(new Uint8Array(asr.authenticatorData)), signature: bytesToBase64url(new Uint8Array(asr.signature)), userHandle: asr.userHandle ? bytesToBase64url(new Uint8Array(asr.userHandle)) : null, }, }), }); const { sessionId, expiresAt } = await verifyRes.json(); // privateKey is now the session signing key until expiresAt — see client-keys.mdx. ``` ```kotlin Android (Kotlin) theme={null} // Uses Jetpack Credential Manager. // implementation("androidx.credentials:credentials:1.3.0") // implementation("androidx.credentials:credentials-play-services-auth:1.3.0") import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.CredentialManager import androidx.credentials.GetCredentialRequest import androidx.credentials.GetPublicKeyCredentialOption import androidx.credentials.PublicKeyCredential suspend fun registerPasskey(context: Context, api: MyBackendApi): EmbeddedWalletSession { // 1. Backend → WebAuthn registration options JSON (challenge, rp, user, ...). val createOptionsJson = api.passkeyRegisterStart() val cm = CredentialManager.create(context) // 2. Authenticator creates the passkey. val createResp = cm.createCredential( context = context, request = CreatePublicKeyCredentialRequest(createOptionsJson), ) as androidx.credentials.CreatePublicKeyCredentialResponse val attestationJson = createResp.registrationResponseJson // 3. Backend -> POST /auth/credentials. Returns a signed-retry challenge. val registerChallenge = api.passkeyRegister(attestationJson, nickname = "This device") // 4. Client stamps the registration payload with an existing session key. val gridWalletSignature = buildGridWalletSignature( existingSessionKeys, registerChallenge.payloadToSign, ) val registerResp = api.passkeyRegisterComplete( requestId = registerChallenge.requestId, gridWalletSignature = gridWalletSignature, ) // 5. Generate client key pair, then ask Grid for an authentication challenge // bound to that public key via POST /auth/credentials/{id}/challenge. val clientKeys = generateClientKeyPair(alias = "embedded-wallet-${registerResp.credentialId}") val challengeResp = api.passkeyChallenge( credentialId = registerResp.credentialId, clientPublicKeyHex = clientKeys.publicKeyHex, ) // 6. Run WebAuthn assertion against Grid-issued challenge. The returned // value is a lowercase hex string; UTF-8 encode it to bytes, then put // those bytes into the platform WebAuthn request format. val webAuthnChallenge = base64UrlEncode( challengeResp.challenge.toByteArray(Charsets.UTF_8), ) val getOptionsJson = buildWebAuthnGetOptionsJson( challenge = webAuthnChallenge, rpId = registerResp.rpId, credentialId = registerResp.credentialId, ) val getResp = cm.getCredential( context = context, request = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(getOptionsJson))), ).credential as PublicKeyCredential val assertionJson = getResp.authenticationResponseJson // 7. Backend -> POST /verify with Request-Id. Returns the AuthSession. return api.passkeyVerify( credentialId = registerResp.credentialId, requestId = challengeResp.requestId, assertionJson = assertionJson, ) } ``` ```swift iOS (Swift) theme={null} import AuthenticationServices final class PasskeyCoordinator: NSObject, ASAuthorizationControllerDelegate { private let api: MyBackendApi private var continuation: CheckedContinuation? private var clientKeys: ClientKeyPair? private var pendingCredentialId: String? private var pendingRequestId: String? init(api: MyBackendApi) { self.api = api } func registerPasskey() async throws -> EmbeddedWalletSession { // 1. Backend → WebAuthn registration options. let options = try await api.passkeyRegisterStart() // 2. Authenticator creates the passkey. let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( relyingPartyIdentifier: options.rpId, ) let request = provider.createCredentialRegistrationRequest( challenge: options.challenge, name: options.user.name, userID: options.user.id, ) let attestation = try await withCheckedThrowingContinuation { (cont: CheckedContinuation< ASAuthorizationPlatformPublicKeyCredentialRegistration, Error >) in let controller = ASAuthorizationController(authorizationRequests: [request]) controller.delegate = AttestationDelegate(cont: cont) controller.performRequests() } // 3. Backend -> POST /auth/credentials. Returns a signed-retry challenge. let registerChallenge = try await api.passkeyRegister( credentialId: attestation.credentialID.base64URLEncoded, clientDataJson: attestation.rawClientDataJSON.base64URLEncoded, attestationObject: attestation.rawAttestationObject!.base64URLEncoded, nickname: "This device", ) // 4. Client stamps the registration payload with an existing session key. let gridWalletSignature = try buildGridWalletSignature( signingKey: existingSessionSigningKey, payloadToSign: registerChallenge.payloadToSign, ) let registered = try await api.passkeyRegisterComplete( requestId: registerChallenge.requestId, gridWalletSignature: gridWalletSignature, ) // 5. Generate client key pair, ask Grid for an authentication challenge // bound to that public key via POST /auth/credentials/{id}/challenge. let keys = generateClientKeyPair() let challengeResp = try await api.passkeyChallenge( credentialId: registered.credentialId, clientPublicKeyHex: keys.publicKeyHex, ) // 6. Run WebAuthn assertion against Grid-issued challenge. The returned // value is a lowercase hex string; UTF-8 encode it as challenge bytes. let assertRequest = provider.createCredentialAssertionRequest( challenge: Data(challengeResp.challenge.utf8), ) assertRequest.allowedCredentials = [ ASAuthorizationPlatformPublicKeyCredentialDescriptor( credentialID: Data(base64URLEncoded: registered.credentialId)!, ), ] let assertion = try await withCheckedThrowingContinuation { (cont: CheckedContinuation< ASAuthorizationPlatformPublicKeyCredentialAssertion, Error >) in let controller = ASAuthorizationController(authorizationRequests: [assertRequest]) controller.delegate = AssertionDelegate(cont: cont) controller.performRequests() } // 7. Backend -> POST /verify with Request-Id. Returns the AuthSession. return try await api.passkeyVerify( credentialId: registered.credentialId, requestId: challengeResp.requestId, clientDataJson: assertion.rawClientDataJSON.base64URLEncoded, authenticatorData: assertion.rawAuthenticatorData.base64URLEncoded, signature: assertion.signature.base64URLEncoded, userHandle: assertion.userID?.base64URLEncoded, ) } } ``` #### WebAuthn → Grid parameter map These are the fields you need to pass through on each hop. **Registration (`/auth/credentials`):** | Browser (`credential`) | Your backend payload | Grid request body | | - | - | - | | `credential.rawId` | `credentialId` | `attestation.credentialId` | | `response.clientDataJSON` | `clientDataJson` | `attestation.clientDataJson` | | `response.attestationObject` | `attestationObject` | `attestation.attestationObject` | | `response.getTransports()` | `transports` | `attestation.transports` | | *(backend session state)* | `challenge` | `challenge` *(top-level, not under `attestation`)* | | *(backend-chosen)* | `nickname` | `nickname` | | *(Grid account id)* | — | `accountId` | **Authentication challenge (`/auth/credentials/{id}/challenge`):** | Source | Your backend payload | Grid request body | | - | - | - | | *(client-generated)* | `clientPublicKey` | `clientPublicKey` | **Assertion (`/auth/credentials/{id}/verify`):** | Browser (`credential`) | Your backend payload | Grid request body | | - | - | - | | `credential.rawId` | `credentialId` | `assertion.credentialId` | | `response.clientDataJSON` | `clientDataJson` | `assertion.clientDataJson` | | `response.authenticatorData` | `authenticatorData` | `assertion.authenticatorData` | | `response.signature` | `signature` | `assertion.signature` | | `response.userHandle` | `userHandle` | `assertion.userHandle` *(optional)* | | *(from `/challenge` response)* | `requestId` | `Request-Id` header | > [!NOTE] > > The WebAuthn spec names the field `clientDataJSON`. Grid spells it `clientDataJson` (lowercase `json`) for consistency with the rest of the API. The **bytes are identical** — only the field name changes. ### Passkey reauthentication When a session expires the client re-verifies without recreating the credential. Reauthentication uses the same `/challenge` → `/verify` shape as the first authentication: generate a fresh client key pair, call `POST /auth/credentials/{id}/challenge` with the new `clientPublicKey`, UTF-8 encode the returned challenge string for `navigator.credentials.get()`, then call `/verify` with the assertion and the matching `Request-Id` header. ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid participant A as Authenticator C->>IB: session expired, re-authenticate C->>C: generateClientKeyPair() C->>IB: POST /my-backend/passkey/challenge (clientPublicKey) IB->>G: POST /auth/credentials/{id}/challenge
          { clientPublicKey } G-->>IB: 200 PasskeyAuthChallenge { challenge, requestId, expiresAt } IB-->>C: { challenge, requestId } C->>A: navigator.credentials.get({ challenge }) A-->>C: assertion C->>IB: POST /my-backend/passkey/reauth (assertion, requestId) IB->>G: POST /auth/credentials/{id}/verify
          Request-Id: requestId
          { type: PASSKEY, assertion } G-->>IB: 200 AuthSession { id, expiresAt } IB-->>C: { sessionId, expiresAt } ``` ## OAuth (OIDC) Use an OAuth credential when your platform already authenticates the user with an OpenID Connect identity provider (Google, Apple, your own IdP) and you want Grid to trust that same identity. Adding OAuth to an initialized Global Account requires an active session from an existing credential and uses the same signed-retry pattern as passkey registration. ### OAuth registration ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid participant OP as OIDC provider C->>OP: Sign-in flow OP-->>C: id_token (OIDC) C->>IB: POST /my-backend/oauth/register { oidcToken } IB->>G: POST /auth/credentials { type: OAUTH, oidcToken, accountId } G-->>IB: 202 { payloadToSign, requestId, expiresAt } IB-->>C: { payloadToSign, requestId } C->>C: stamp(payloadToSign, existingSessionPrivateKey) C->>IB: { stamp, requestId } IB->>G: Same POST
          Grid-Wallet-Signature: stamp
          Request-Id: requestId G->>OP: fetch .well-known/openid-configuration + jwks G-->>IB: 201 AuthMethod IB-->>C: { credentialId } C->>C: generateClientKeyPair() C->>OP: get fresh id_token (iat within 60s) OP-->>C: id_token C->>IB: POST /my-backend/oauth/verify { oidcToken, clientPublicKey } IB->>G: POST /auth/credentials/{id}/verify { type: OAUTH, oidcToken, clientPublicKey } G-->>IB: 200 AuthSession IB-->>C: { sessionId, expiresAt } ``` Grid validates the OIDC token signature against the issuer's JWKS on every call and requires `iat` to be no more than **60 seconds** older than the request. Use a fresh token for each `verify` call; cached tokens will fail. The token identity (`iss`, `aud`, and `sub`) must match the OAuth credential being verified. > [!NOTE] > > In sandbox, OAuth still uses JWT-shaped OIDC tokens. The sandbox skips real IdP signature verification, but it validates the same identity and freshness claims. For `verify`, include `nonce` equal to the lowercase hex SHA-256 digest of the `clientPublicKey` string exactly as sent: the compressed key in lowercase hex. See [Sandbox testing](/global-accounts/platform-tools/sandbox-testing#oauth-oidc-token). ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "OAUTH", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9..." }' ``` **Response on the signed retry:** `201 AuthMethod` with `nickname` populated from the OIDC token's `email` claim. ### OAuth verify / reauthentication `POST /auth/credentials/{id}/verify` is also the reauthentication path — call it with a fresh OIDC token whenever the session expires. ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "OAUTH", "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9...", "clientPublicKey": "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" }' ``` ## Email OTP The lowest-friction credential type — works on any device with email access and requires no biometric hardware, identity provider, or client-side setup beyond an input field for the code. Global Accounts are initialized with an `EMAIL_OTP` credential; call `POST /auth/credentials` for `EMAIL_OTP` only if the credential was removed and you need to add it back. ### Default Email OTP credential Grid creates the first `EMAIL_OTP` credential when the Global Account is provisioned. The credential uses the customer email on file for the internal account. To authenticate with it, send an OTP challenge, then verify using the secure encrypted OTP flow. The client never sends the plaintext OTP code. Instead, it HPKE-encrypts the code (together with a fresh public key) to an enclave bundle returned from the challenge. The server is a pass-through and never sees the plaintext. ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid participant E as Email C->>IB: POST /my-backend/otp/challenge { credentialId } IB->>G: POST /auth/credentials/{id}/challenge G->>E: deliver OTP email (to customer email on file) G-->>IB: 200 AuthMethod + otpEncryptionTargetBundle IB-->>C: { otpEncryptionTargetBundle } E-->>C: OTP code C->>C: generateClientKeyPair() (TEK) C->>C: HPKE-encrypt { otp_code, public_key } → encryptedOtpBundle C->>IB: POST /my-backend/otp/verify { encryptedOtpBundle } IB->>G: POST /auth/credentials/{id}/verify { type: EMAIL_OTP, encryptedOtpBundle } G-->>IB: 202 { payloadToSign, requestId } IB-->>C: { payloadToSign, requestId } C->>C: sign(payloadToSign, tekPrivateKey) C->>IB: POST /my-backend/otp/verify/complete { stamp, requestId } IB->>G: Same POST + Grid-Wallet-Signature + Request-Id G-->>IB: 200 AuthSession IB-->>C: { expiresAt } ``` ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/challenge" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (200):** ```json theme={null} { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000004", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "otpEncryptionTargetBundle": "{\"version\":\"v1.0.0\",\"data\":\"7b22...\",\"dataSignature\":\"3045...\",\"enclaveQuorumPublic\":\"04a1...\"}", "createdAt": "2026-04-19T12:00:00Z", "updatedAt": "2026-04-19T12:00:00Z" } ``` The client generates a fresh P-256 key pair (the TEK — Target Encryption Key), HPKE-encrypts `{otp_code, public_key}` under `otpEncryptionTargetBundle`, and submits the encrypted payload. `public_key` must be the compressed public key (`02` or `03` prefix). See [Encrypt the OTP code](client-keys#encrypt-the-otp-code-email_otp-only) for implementation details. Then verify with the encrypted OTP bundle: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "EMAIL_OTP", "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" }' ``` **Response (202):** ```json theme={null} { "type": "EMAIL_OTP", "payloadToSign": "{\"login\":{\"publicKey\":\"02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31\"},\"tokenId\":\"7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21\",\"type\":\"USAGE_TYPE_LOGIN\"}", "requestId": "Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21", "expiresAt": "2026-04-19T12:05:00Z" } ``` The client stamps the exact UTF-8 bytes of `payloadToSign`, unchanged, with the TEK private key (the same key whose public key was encrypted in the bundle), then retries the same request body with the stamp: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ -d '{ "type": "EMAIL_OTP", "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" }' ``` **Response (200):** ```json theme={null} { "id": "Session:019542f5-b3e7-1d02-0000-000000000003", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-19T12:00:01Z", "updatedAt": "2026-04-19T12:00:01Z", "expiresAt": "2026-04-19T12:15:01Z" } ``` The TEK public key becomes the session API key. The response carries no key material: the client already holds the session signing key (the TEK private key it generated). `verify` shares its terminal status code with the still-processing response: a `200` here is only a session if the body isn't `{ "status": "PROCESSING" }`. See [handling a still-processing response](#handling-a-still-processing-response) — if you get `PROCESSING`, re-send the identical signed retry (same `encryptedOtpBundle`, `Grid-Wallet-Signature`, and `Request-Id`) until you get the session shown above. > [!NOTE] > > **In sandbox, the OTP code is always `000000`** — encrypt that value in the bundle. The sandbox runs real HPKE end-to-end; the only shortcut is skipping email delivery. See [Client keys](client-keys#encrypt-the-otp-code-email_otp-only) for the encryption flow. ### Resending an OTP If the code expires or the email didn't arrive, re-issue the challenge with `POST /auth/credentials/{id}/challenge`. This sends a fresh OTP email and leaves the `AuthMethod` otherwise untouched. `challenge` can also return a [still-processing `200`](#handling-a-still-processing-response) while the resend settles at the wallet provider — re-request the challenge until you get the `AuthMethod` back rather than a fresh OTP being sent twice. ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/challenge" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [!WARNING] > > Challenge re-issues are rate-limited. On `429`, back off for the duration returned in the `Retry-After` header before retrying. ### Email OTP reauthentication Same pattern as the first activation: call `/challenge` to send a new OTP and receive a fresh `otpEncryptionTargetBundle`, generate a new TEK key pair, build the `encryptedOtpBundle`, and complete the two-step verify flow. ### Changing the sign-in email or phone number The `EMAIL_OTP` address is the customer email on file, and the `SMS_OTP` number is the customer phone number. The customer must prove they control a new address or number before it replaces the one they sign in with. Changing it takes four calls, and the same steps apply to `phoneNumber` with `SMS_OTP`. **1. Register the new address** Call `POST /auth/credentials` with the new address. Grid sends a code to it and returns the replacement credential with an `otpEncryptionTargetBundle`. The current credential keeps working until the change lands. ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "EMAIL_OTP", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "email": "jane.new@example.com" }' ``` **2. Verify the code** Encrypt the code the customer received to that `otpEncryptionTargetBundle`, as for a login, and send it to `POST /auth/credentials/{id}/verify` with the replacement's id. Grid answers `204` with no session. A replacement that is not verified within 24 hours expires. **3. Update the customer** Call `PATCH /customers/{customerId}` with only `customerType` and the new address. Grid answers `202` with `payloadToSign`, `requestId`, and `expiresAt`. Make this call soon after verifying. If the verification would lapse before the challenge does, Grid returns `400 AUTH_CREDENTIAL_VERIFICATION_EXPIRED`, and the customer must verify again. ```bash theme={null} curl -X PATCH "$GRID_BASE_URL/customers/Customer:019542f5-b3e7-1d02-0000-000000000001" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerType": "INDIVIDUAL", "email": "jane.new@example.com" }' ``` **4. Signed retry** The client stamps `payloadToSign` with an active session signing key on the same account. Retry the identical request with `Grid-Wallet-Signature` and `Request-Id`. Grid answers `200` with the updated customer, and the new address becomes the account's `EMAIL_OTP` credential in place of the old one. A `200` with `{ "status": "PROCESSING" }` means the change is still landing: re-send the identical signed request until it returns the customer. A `PATCH` that changes the sign-in address without a verified replacement returns `400 AUTH_CREDENTIAL_VERIFICATION_REQUIRED`. The change must be sent on its own, so a request that also changes other customer fields returns `400 INVALID_INPUT`. Customers without an `EMAIL_OTP` or `SMS_OTP` credential of that type change `email` or `phoneNumber` with a plain update. ## Managing credentials Every Global Account starts with a single `EMAIL_OTP` credential — the one used in the [quickstart](overview#quickstart). In production, encourage customers to register a backup credential, such as a passkey or OAuth credential, so the account is recoverable if their primary device is lost. Adding, revoking, and rotating credentials after the first all go through the same **two-step signed-retry** pattern. ### List credentials ```bash theme={null} curl -X GET "$GRID_BASE_URL/auth/credentials?accountId=InternalAccount:019542f5-b3e7-1d02-0000-000000000002" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (200):** ```json theme={null} { "data": [ { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000001", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "PASSKEY", "credentialId": "KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Pxg6jo_o0hYiew", "nickname": "iPhone Face-ID", "createdAt": "2026-04-08T15:30:01Z", "updatedAt": "2026-04-08T15:30:01Z" }, { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000004", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-09T10:15:00Z", "updatedAt": "2026-04-09T10:15:00Z" } ] } ``` The response is not paginated — each account holds a small, bounded number of credentials. ### The signed-retry pattern Adding an additional credential, revoking a credential, refreshing or revoking a session, exporting a wallet, updating wallet privacy, and changing a sign-in email or phone number all share the same shape: ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid IB->>G: Request (no headers) G-->>IB: 202 { payloadToSign, requestId, expiresAt } IB-->>C: { payloadToSign, requestId } C->>C: stamp(payloadToSign, sessionPrivateKey) C->>IB: { stamp } IB->>G: Same request
          Grid-Wallet-Signature: stamp
          Request-Id: requestId alt still settling at the wallet provider G-->>IB: 200 { status: "PROCESSING" } IB->>G: Re-send the identical signed request end G-->>IB: 2xx (terminal success) IB-->>C: done ``` Key rules: * Always stamp the `payloadToSign` **byte-for-byte as Grid returned it**. Do not re-parse, re-serialize, or modify whitespace. * Sign with the **session private key** held on the client — never ship it back to your backend. * The retry must reach Grid before `expiresAt` (typically 5 minutes from issue). * The `requestId` is returned as `Request:` and is single-use; reusing one yields `401`. ### Handling a still-processing response The signed retry can come back `200` with a `WalletOperationProcessing` body instead of the terminal success shown above: ```json theme={null} { "status": "PROCESSING", "message": "This login is still being processed. Retry the same request in a moment." } ``` This means Grid's wallet provider accepted the operation but hasn't settled it yet — the request is not lost, and nothing failed. **Design for this as the normal path, not a rare edge case**: handle it the same way you handle the terminal success and error responses below, not as a bolted-on afterthought. Every signed-retry endpoint can return it on the retry step: | Endpoint | Action | | - | - | | `POST /auth/credentials` | Add a credential | | `POST /auth/credentials/{id}/challenge` | Re-send an OTP / re-issue a challenge | | `POST /auth/credentials/{id}/verify` | Log in / verify a credential | | `POST /auth/sessions/{id}/refresh` | Refresh a session | | `DELETE /auth/credentials/{id}` | Revoke a credential | | `DELETE /auth/sessions/{id}` | Revoke a session | | `POST /internal-accounts/{id}/export` | Export wallet credentials | | `PATCH /customers/{customerId}` | Change a sign-in email or phone number | To handle it: 1. **Re-send the exact same signed request** — identical body, `Grid-Wallet-Signature`, and `Request-Id`. Don't regenerate the stamp or start a fresh request; Grid correlates the retry with the same underlying operation and returns its real outcome once the provider settles, rather than starting a second one. 2. **Show a pending state in your UI** while you retry — "removing…" for a credential revoke, "signing in…" for a login — instead of treating `PROCESSING` as an error or leaving the caller blocked with no feedback. 3. **Keep retrying with backoff** until you get a terminal response — the success code above, or an error. Grid also reconciles the operation to its terminal state on its own, so even a client that stops retrying and checks back later (for example via `GET /auth/credentials` or `GET /auth/sessions`) will see the settled result. **Wallet export is the exception**: Grid never stores the encrypted credentials, so re-sending the signed retry is the only way to receive them. Grid's own reconciliation settles the operation but has nothing to hand back, and the export challenge expires like any other (typically 5 minutes) — if it lapses, start a fresh export. > [!NOTE] > > `verify`, `challenge`, `export`, and the customer update already use `200` for their terminal success body (`AuthSession`, the challenge response, the encrypted credentials, or the customer). For those four, `200` alone doesn't tell you which case you're in — check the response body's `status` field for `"PROCESSING"` to distinguish it from a settled success. The other four endpoints (`add credential`, `session refresh`, `revoke credential`, `revoke session`) use `201` or `204` on success, so a `200` by itself already means still-processing. ### Add an additional credential Requires an active session on an *existing* credential on the same account. The first call uses the normal credential-create body; Grid detects the pre-existing credential and responds `202` instead of `201`. `OAUTH` and `PASSKEY` are the typical additional credential types. `EMAIL_OTP` can be added back only after the existing email OTP credential has been removed, because each account supports one. **1. First call — receive the challenge** ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "OAUTH", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9..." }' ``` **Response (202):** ```json theme={null} { "type": "OAUTH", "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"oauthProviders\":[{\"oidcToken\":\"eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9...\",\"providerName\":\"Google\"}],\"userId\":\"user_2m9F...\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_CREATE_OAUTH_PROVIDERS\"}", "requestId": "Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21", "expiresAt": "2026-04-08T15:35:00Z" } ``` **2. Client stamps the payload** Send `payloadToSign` to the client. The client builds a Grid wallet signature with the session signing key from the existing credential's active session — see [signing payloads](client-keys#2-sign-a-payloadtosign). **3. Signed retry — credential is created** Re-run the same request with the stamp and request id in headers: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ -d '{ "type": "OAUTH", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9..." }' ``` **Response (201):** a plain `AuthMethod`. If the underlying wallet-provider activity is still settling, this returns `200 { status: "PROCESSING" }` instead — see [handling a still-processing response](#handling-a-still-processing-response); re-send the identical signed request until you get the `201`. **4. Activate the new credential** Activate the new credential the same way you would activate the first credential of that type — `OAUTH` goes straight to `POST /auth/credentials/{id}/verify` with a fresh `clientPublicKey`; `EMAIL_OTP` uses the `otpEncryptionTargetBundle` from the signed-retry registration response when present, or first calls `POST /auth/credentials/{id}/challenge` if the bundle is absent; `PASSKEY` first calls `POST /auth/credentials/{id}/challenge` with the `clientPublicKey` to get a Grid-issued WebAuthn challenge, then `POST /auth/credentials/{id}/verify` with the assertion and the `Request-Id` header. > [!NOTE] > > The account holder may have one `EMAIL_OTP` credential per internal account; an administrator's credentials, added with `POST /auth/admin-credentials`, are not counted. An administrator's session cannot add credentials with `POST /auth/credentials` (`403 FORBIDDEN`); administrators add their own with `POST /auth/admin-credentials`. Multiple distinct `PASSKEY` credentials are allowed; registering the same WebAuthn credentialId twice returns `400 PASSKEY_CREDENTIAL_ALREADY_EXISTS`. ### Revoke a credential A credential is revoked by signing with a session from **a different credential on the same account**. This prevents a compromised credential from revoking itself to lock the legitimate owner out. An account must keep at least one credential — if only one exists, the revoke call returns `400`. **1. First call — receive the challenge** ```bash theme={null} curl -X DELETE "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (202):** ```json theme={null} { "type": "PASSKEY", "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"authenticatorIds\":[\"authenticator_2m9F...\"],\"userId\":\"user_2m9F...\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_DELETE_AUTHENTICATORS\"}", "requestId": "Request:9f7a2c10-5e88-4fb1-bd0e-1c3a8e7b2d45", "expiresAt": "2026-04-08T15:35:00Z" } ``` **2. Client stamps with a different credential's session** The client stamps `payloadToSign` with the session signing key of an active session on any *other* credential (not the one being revoked). **3. Signed retry — credential is revoked** ```bash theme={null} curl -X DELETE "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:9f7a2c10-5e88-4fb1-bd0e-1c3a8e7b2d45" ``` **Response:** `204 No Content`. All active sessions issued by the revoked credential are also revoked. If the revocation is still settling at the wallet provider, this returns `200 { status: "PROCESSING" }` instead — [re-send the identical `DELETE`](#handling-a-still-processing-response) until you get the `204`, and show the credential as "removing…" in your UI in the meantime rather than assuming success or failure. # Client keys & signing Source: https://agents-docs.lightspark.com/global-accounts/client-keys Generate the P-256 client key pair, hold the session signing key, and sign account actions on Web, iOS, and Android Every signed Global Account action uses two key pairs: | Key pair | Where it lives | What it does | | - | - | - | | **Client key pair** (P-256) | On the customer's device, generated fresh per session-issuing or export request | For authentication and session refresh, its private key becomes the session signing key. For wallet export, its public key is the recipient key Grid encrypts the wallet credentials to. Ephemeral — one pair per authentication, session refresh, or wallet export. | | **Session signing key** (P-256) | Held on the customer's device | Signs every account action for the lifetime of the session (default 15 minutes). It is the private key of the client key pair sent on the request that issued the session. | This page covers generating the client key pair, sending the public key to your backend, holding the session signing key, and signing payloads. Everything here runs **on the client**; your integrator backend only relays opaque byte strings. ## 1. Generate a client key pair The key pair you generate here *is* your session key: you keep the private key on the device and use it directly to sign account actions. Grid never transmits a session key, so session-issuing responses carry no key material. Generate it as a P-256 **signing** key (ECDSA). Generate a fresh key pair for every authentication and session refresh. Where the public key goes depends on the flow: * `PASSKEY`: send it as `clientPublicKey` on `POST /auth/credentials/{id}/challenge`. * `OAUTH`: send it as `clientPublicKey` on `POST /auth/credentials/{id}/verify`. * `EMAIL_OTP`: put it inside `encryptedOtpBundle` on `POST /auth/credentials/{id}/verify`. See [Encrypt the OTP code](#encrypt-the-otp-code-email_otp-only). * Session refresh: send it as `clientPublicKey` on both `/auth/sessions/{id}/refresh` calls. Wallet export uses a different kind of key pair. See [Wallet export key pair](#wallet-export-key-pair). Keep the private key in device-local secure storage (iOS Keychain, Android Keystore, or in the browser, `IndexedDB` encrypted under a non-extractable Web Crypto key). Send the public key through your integrator backend as a **compressed** SEC1 point in **lowercase** hex: 66 characters, a `02` or `03` prefix followed by the 32-byte X coordinate. Grid rejects an uppercase or uncompressed key with `400 INVALID_INPUT`. > [!TIP] > > For local development, you can generate a P-256 key pair from the command line: > > ```bash theme={null} > # Private key (PKCS#8 PEM) > openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out private.pem > > # Compressed public key as lowercase hex > openssl ec -in private.pem -pubout -conv_form compressed -outform DER 2>/dev/null \ > | tail -c 33 | xxd -p -c 33 > ``` ```typescript Web (TypeScript) theme={null} // npm i @noble/curves import { p256 } from "@noble/curves/nist.js"; function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } // Generate a P-256 signing key pair. The 32-byte private key becomes the // session signing key, so store it encrypted at rest on the device. function generateClientKeyPair(): { privateKey: Uint8Array; publicKeyHex: string; } { const privateKey = p256.utils.randomSecretKey(); // Compressed SEC1 form: 33 bytes, 0x02 or 0x03 followed by X. const publicKeyHex = bytesToHex(p256.getPublicKey(privateKey, true)); return { privateKey, publicKeyHex }; } ``` ```kotlin Android (Kotlin) theme={null} import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import java.math.BigInteger import java.security.KeyPairGenerator import java.security.interfaces.ECPublicKey import java.security.spec.ECGenParameterSpec data class ClientKeyPair(val alias: String, val publicKeyHex: String) private fun BigInteger.toFixed32(): ByteArray { val bytes = toByteArray() return ByteArray(32).also { out -> val copyFrom = maxOf(0, bytes.size - 32) val copyLength = bytes.size - copyFrom System.arraycopy(bytes, copyFrom, out, 32 - copyLength, copyLength) } } fun generateClientKeyPair(alias: String): ClientKeyPair { val generator = KeyPairGenerator.getInstance( KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore", ) generator.initialize( KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN) .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) .setDigests(KeyProperties.DIGEST_SHA256) .build(), ) val point = (generator.generateKeyPair().public as ECPublicKey).w // Compressed SEC1 form: 0x02 for an even Y, 0x03 for an odd Y, then X. val prefix = if (point.affineY.testBit(0)) 0x03.toByte() else 0x02.toByte() val compressed = byteArrayOf(prefix) + point.affineX.toFixed32() val hex = compressed.joinToString("") { "%02x".format(it) } return ClientKeyPair(alias = alias, publicKeyHex = hex) } ``` ```swift iOS (Swift) theme={null} import CryptoKit struct ClientKeyPair { let privateKey: P256.Signing.PrivateKey let publicKeyHex: String } func generateClientKeyPair() -> ClientKeyPair { let privateKey = P256.Signing.PrivateKey() // compressedRepresentation is the 33-byte SEC1 form (0x02 or 0x03, then X). let raw = privateKey.publicKey.compressedRepresentation let hex = raw.map { String(format: "%02x", $0) }.joined() return ClientKeyPair(privateKey: privateKey, publicKeyHex: hex) } ``` > [!NOTE] > > The private key **must not leave the device**. Your integrator backend only ever sees `publicKeyHex`. ### Wallet export key pair Wallet export encrypts the wallet credentials to the key you send, so it needs a P-256 **key agreement** (ECDH) key pair rather than a signing key. Generate a fresh one for each export and discard the private key after decrypting. Send its public key as `clientPublicKey` on both `/export` calls, as an **uncompressed** SEC1 point in **lowercase** hex: 130 characters, a `04` prefix followed by the 32-byte X and 32-byte Y coordinates. The samples below produce that form. See [Exporting a wallet](exporting-wallet) for the full flow. ```typescript Web (TypeScript) theme={null} function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } // Generate a non-extractable P-256 key pair in the browser. // The private key never leaves Web Crypto; only the public key is exported. async function generateExportKeyPair(): Promise<{ keyPair: CryptoKeyPair; publicKeyHex: string; }> { const keyPair = await crypto.subtle.generateKey( { name: "ECDH", namedCurve: "P-256" }, false, // private key non-extractable ["deriveBits"], ); const raw = new Uint8Array( await crypto.subtle.exportKey("raw", keyPair.publicKey), ); // exportKey("raw") returns the 65-byte uncompressed form (0x04 || X || Y). const publicKeyHex = bytesToHex(raw); return { keyPair, publicKeyHex }; } ``` ```kotlin Android (Kotlin) theme={null} import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import java.math.BigInteger import java.security.KeyPairGenerator import java.security.interfaces.ECPublicKey import java.security.spec.ECGenParameterSpec data class ExportKeyPair(val alias: String, val publicKeyHex: String) // toFixed32() is the helper from the client key pair sample above. fun generateExportKeyPair(alias: String): ExportKeyPair { val generator = KeyPairGenerator.getInstance( KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore", ) generator.initialize( KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_AGREE_KEY) .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) .build(), ) val point = (generator.generateKeyPair().public as ECPublicKey).w val uncompressed = byteArrayOf(0x04) + point.affineX.toFixed32() + point.affineY.toFixed32() val hex = uncompressed.joinToString("") { "%02x".format(it) } return ExportKeyPair(alias = alias, publicKeyHex = hex) } ``` ```swift iOS (Swift) theme={null} import CryptoKit struct ExportKeyPair { let privateKey: P256.KeyAgreement.PrivateKey let publicKeyHex: String } func generateExportKeyPair() -> ExportKeyPair { let privateKey = P256.KeyAgreement.PrivateKey() // x963Representation returns uncompressed SEC1 (65 bytes starting with 0x04). let raw = privateKey.publicKey.x963Representation let hex = raw.map { String(format: "%02x", $0) }.joined() return ExportKeyPair(privateKey: privateKey, publicKeyHex: hex) } ``` ## Encrypt the OTP code (`EMAIL_OTP` only) `EMAIL_OTP` credentials never send the OTP code in plaintext. Instead, the client HPKE-encrypts the code, together with its public key, to an enclave key, so the code is unreadable in transit and Grid is only a pass-through. Grid returns an `otpEncryptionTargetBundle` whenever it initiates or reissues an OTP challenge, including `POST /auth/credentials/{id}/challenge` and add-EMAIL\_OTP signed-retry responses. First-time EMAIL\_OTP wallet bootstrap registration can omit it; if the registration response has no bundle, call `POST /auth/credentials/{id}/challenge` for that credential before verifying. The bundle is a signed enclave bundle whose `data` field is hex-encoded JSON carrying the enclave's HPKE target key as `targetPublic`. Pull out `targetPublic`, HPKE-encrypt `{ otp_code, public_key }` to it, and submit the library's `{ encappedPublic, ciphertext }` output as `encryptedOtpBundle` on `POST /auth/credentials/{id}/verify`. `public_key` must be the **compressed** public key in lowercase hex: 66 characters starting with `02` or `03`. The step 1 samples produce this form. The OTP login challenge is bound to this exact key, so the same compressed hex must be the `publicKey` in the `Grid-Wallet-Signature` stamp on the signed retry (see [step 2](#2-sign-a-payloadtosign)); a different value there fails the login. In production, an uncompressed key makes the enclave reject the OTP code, even when the code is correct. The first verify call then returns: ```json theme={null} {"code": "INVALID_INPUT", "reason": "Invalid wallet request."} ``` Grid returns this same response for other rejected requests too, including a mistyped OTP code. If you get it with a code you know is correct, check the key format first. Use an HPKE library so you don't hand-roll the suite, `info`, or AAD. The helper below assumes your crypto layer returns the JSON string Grid expects for `encryptedOtpBundle`. ```typescript Web (TypeScript) theme={null} // npm i @noble/hashes import { hexToBytes } from "@noble/hashes/utils.js"; declare function hpkeEncryptToGridBundle(args: { plainTextBuf: Uint8Array; targetKeyBuf: Uint8Array; }): string; // otpEncryptionTargetBundle: from the registration response when present, or /challenge // clientPublicKeyHex: the compressed public key from step 1 // otp: the code the user typed ("000000" in sandbox) function buildEncryptedOtpBundle( otpEncryptionTargetBundle: string, clientPublicKeyHex: string, otp: string, ): string { // Pull the enclave's target key out of the signed bundle. const { data } = JSON.parse(otpEncryptionTargetBundle) as { data: string }; const { targetPublic } = JSON.parse( new TextDecoder().decode(hexToBytes(data)), ) as { targetPublic: string }; // The enclave expects snake_case keys. const plainTextBuf = new TextEncoder().encode( JSON.stringify({ otp_code: otp, public_key: clientPublicKeyHex }), ); return hpkeEncryptToGridBundle({ plainTextBuf, targetKeyBuf: hexToBytes(targetPublic), }); } ``` > [!NOTE] > > A production client should also verify the bundle's `dataSignature` against its `enclaveQuorumPublic` before trusting `targetPublic`. The private key of the pair you generated in step 1 stays on the device. It becomes the session signing key once verification completes (see [Sign a `payloadToSign`](#2-sign-a-payloadtosign)). ## 2. Sign a `payloadToSign` Grid returns `payloadToSign` strings from several endpoints: * `POST /quotes` (when the source is a Global Account) — the quote's `paymentInstructions[].accountOrWalletInfo.payloadToSign`. * `POST /auth/credentials/{id}/verify` (`EMAIL_OTP` or `SMS_OTP`) — 202 response body; use the TEK private key generated for this login. * `POST /auth/credentials` (adding an additional credential) — 202 response body. * `DELETE /auth/credentials/{id}`, `DELETE /auth/sessions/{id}`, `POST /auth/sessions/{id}/refresh`, `POST /internal-accounts/{id}/export`, `PATCH /internal-accounts/{id}`, `PATCH /customers/{id}` for sign-in email or phone number changes — all 202 response bodies. Stamp the exact UTF-8 bytes of the `payloadToSign` string **as returned** (do not re-parse, re-serialize, or trim whitespace). For OTP verification, use the matching TEK private key; for other signed wallet actions, use an active session signing key. Build a Grid wallet signature: take its compressed P-256 public key in lowercase hex, sign the payload with the private scalar, then base64url-encode the signature envelope: ```json theme={null} { "publicKey": "", "scheme": "SIGNATURE_SCHEME_TK_API_P256", "signature": "" } ``` Pass that full stamp as the `Grid-Wallet-Signature` header on the retry (and, for endpoints that use it, echo the 202 `requestId` as `Request-Id`). > [!NOTE] > > Sign with real stamps in sandbox too. Sandbox verifies them exactly as production does. OTP verification requires a stamp over the returned `payloadToSign` with the TEK private key. ```typescript Web (TypeScript) theme={null} declare function deriveCompressedP256PublicKey(privateKeyHex: string): string; declare function signP256Sha256Der(args: { content: string; privateKeyHex: string; }): Promise; const GRID_STAMP_SCHEME = "SIGNATURE_SCHEME_TK_API_P256"; function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } function base64url(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) { binary += String.fromCharCode(byte); } return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } async function buildGridWalletSignature( sessionPrivateKeyBytes: Uint8Array, // 32 bytes, the step 1 private key payloadToSign: string, ): Promise { const apiPrivateKey = bytesToHex(sessionPrivateKeyBytes); const apiPublicKey = deriveCompressedP256PublicKey(apiPrivateKey); const signature = await signP256Sha256Der({ content: payloadToSign, privateKeyHex: apiPrivateKey, }); const stamp = JSON.stringify({ publicKey: apiPublicKey, scheme: GRID_STAMP_SCHEME, signature, }); return base64url(new TextEncoder().encode(stamp)); } ``` ```kotlin Android (Kotlin) theme={null} import android.util.Base64 import java.security.KeyStore import java.security.PrivateKey import java.security.Signature private const val GRID_STAMP_SCHEME = "SIGNATURE_SCHEME_TK_API_P256" private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } private fun base64url(value: String): String = Base64.encodeToString( value.toByteArray(Charsets.UTF_8), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP, ) fun buildGridWalletSignature( sessionKeys: ClientKeyPair, // from generateClientKeyPair in step 1 payloadToSign: String, ): String { val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } val privateKey = keyStore.getKey(sessionKeys.alias, null) as PrivateKey val signer = Signature.getInstance("SHA256withECDSA").apply { initSign(privateKey) update(payloadToSign.toByteArray(Charsets.UTF_8)) } val derSignatureHex = signer.sign().hex() // JCE returns DER-encoded ECDSA. val stampJson = """{"publicKey":"${sessionKeys.publicKeyHex}","scheme":"$GRID_STAMP_SCHEME","signature":"$derSignatureHex"}""" return base64url(stampJson) } ``` ```swift iOS (Swift) theme={null} import CryptoKit import Foundation func buildGridWalletSignature( signingKey: P256.Signing.PrivateKey, // from generateClientKeyPair in step 1 payloadToSign: String, ) throws -> String { let payload = Data(payloadToSign.utf8) let signature = try signingKey.signature(for: payload) let signatureHex = signature.derRepresentation.map { String(format: "%02x", $0) }.joined() let publicKeyHex = signingKey.publicKey.compressedRepresentation .map { String(format: "%02x", $0) } .joined() let stampJson = #"{"publicKey":"\#(publicKeyHex)","scheme":"SIGNATURE_SCHEME_TK_API_P256","signature":"\#(signatureHex)"}"# return Data(stampJson.utf8) .base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } ``` Your backend adds the stamp to the retry request: ```bash theme={null} curl -X POST "https://api.lightspark.com/grid/2025-10-13/quotes/Quote:019542f5-b3e7-1d02-0000-000000000006/execute" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" ``` ## Session lifetime Sessions are valid for 15 minutes by default. The `AuthSession.expiresAt` field tells you exactly when the session signing key stops being accepted. After expiry, the client must re-verify the credential (see [Authentication](authentication)) to obtain a fresh session. > [!WARNING] > > If the device is lost or compromised, the user should add a second credential from a trusted device and revoke the compromised one — see [Managing credentials](authentication#managing-credentials). To end the current browser or app session without touching credentials, see [Sessions](managing-sessions). # Playground Source: https://agents-docs.lightspark.com/global-accounts/demo Create a Global Account and watch the API calls fire in real time # Exporting a wallet Source: https://agents-docs.lightspark.com/global-accounts/exporting-wallet Let a customer take the seed of their Global Account off Grid Exporting a wallet returns the wallet's mnemonic seed, encrypted to the client's public key. The customer decrypts it on their device and can then import the wallet into any compatible self-custody client. Grid never sees the plaintext seed leaving the system. Export uses the same [signed-retry pattern](authentication#the-signed-retry-pattern) as credential and session revocation — the initial `POST` returns a `payloadToSign`, and the signed retry returns the encrypted seed. Generate a fresh P-256 key agreement key pair specifically for the export (see [Wallet export key pair](client-keys#wallet-export-key-pair)). Send its public key as `clientPublicKey` on both export requests, as an uncompressed key in lowercase hex (130 characters, `04` prefix), then decrypt `encryptedWalletCredentials` with the matching private key after the signed retry succeeds. ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid IB->>G: POST /internal-accounts/{id}/export { clientPublicKey } G-->>IB: 202 { payloadToSign, requestId, expiresAt } IB-->>C: { payloadToSign, requestId } C->>C: stamp(payloadToSign, sessionPrivateKey) C->>IB: { stamp } IB->>G: POST /internal-accounts/{id}/export { same clientPublicKey }
          Grid-Wallet-Signature
          Request-Id alt still settling at the wallet provider G-->>IB: 200 { status: "PROCESSING" } IB->>G: Re-send the identical signed request end G-->>IB: 200 { id, encryptedWalletCredentials } IB-->>C: { encryptedWalletCredentials } C->>C: decrypt with client private key
          → mnemonic ``` **1. First call — receive the challenge** ```bash theme={null} curl -X POST "$GRID_BASE_URL/internal-accounts/InternalAccount:019542f5-b3e7-1d02-0000-000000000002/export" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "clientPublicKey": "04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2" }' ``` **Response (202):** ```json theme={null} { "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"targetPublicKey\":\"04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2\",\"walletId\":\"wallet_2m9F...\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_EXPORT_WALLET\"}", "requestId": "Request:c3f8a614-47e2-4a19-9f5d-2b0a91d47e08", "expiresAt": "2026-04-19T12:10:00Z" } ``` **2. Client stamps the payload** Build a Grid wallet signature over `payloadToSign` with an active session signing key on the account. Keep the export private key on the client; Grid will use the matching `clientPublicKey` from step 1 to seal the wallet credentials. **3. Signed retry — receive the encrypted seed** ```bash theme={null} curl -X POST "$GRID_BASE_URL/internal-accounts/InternalAccount:019542f5-b3e7-1d02-0000-000000000002/export" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:c3f8a614-47e2-4a19-9f5d-2b0a91d47e08" \ -d '{ "clientPublicKey": "04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2" }' ``` **Response (200):** ```json theme={null} { "id": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "encryptedWalletCredentials": "{\"version\":\"v1.0.0\",\"data\":\"7b22656e6361707065645075626c6963223a2230346634356632612e2e2e222c2263697068657274657874223a22316661313032333339302e2e2e222c226f7267616e697a6174696f6e4964223a226f72675f326d39462e2e2e227d\",\"dataSignature\":\"3045022100...\",\"enclaveQuorumPublic\":\"04a1b2c3...\"}" } ``` Export shares its terminal status code with the still-processing response: a `200` here is only the encrypted seed if the body isn't `{ "status": "PROCESSING" }`. See [handling a still-processing response](authentication#handling-a-still-processing-response) — if you get `PROCESSING`, re-send the identical signed retry (same `clientPublicKey`, `Grid-Wallet-Signature`, and `Request-Id`) until you get the credentials. Because Grid never stores them, the re-send is the only way to receive them; subscribe to `wallet_operation.completed` to know when it will succeed. The `Request-Id` challenge is consumed only by the attempt that actually returns credentials, so a `PROCESSING` response leaves it usable. **4. Decrypt on the client** `encryptedWalletCredentials` is a signed wallet export envelope. Parse the envelope, verify `dataSignature` against `enclaveQuorumPublic`, decode the hex `data` JSON to get `encappedPublic` and `ciphertext`, then decrypt with the export private key that matches the `clientPublicKey` you sent on both export requests. The plaintext is a BIP-39 mnemonic (the wallet's master seed). > [!WARNING] > > The exported mnemonic is the master key of the self-custody wallet. After decryption the customer is the only custodian — if the mnemonic is lost, the funds are lost. Surface appropriate warnings in your UI before running an export. # Implementation overview Source: https://agents-docs.lightspark.com/global-accounts/implementation-overview End-to-end walkthrough: create a customer, find their Global Account, register a passkey, fund the account, and execute a signed withdrawal. ## Prerequisites > [!WARNING] > > Customers who hold a Global Account must be KYC/KYB verified before any account funds can move from or to fiat rails. This quickstart picks up after KYC is complete. > > In sandbox, customers are automatically KYC approved on creation so you can skip straight to account setup. You also need: * A platform configured with `USDB` in its supported currencies. In sandbox, USDB is enabled by default alongside `USD` and `USDC`. * Sandbox or production API credentials with access to the `Embedded Wallet Auth` and `Internal Accounts` endpoints. ```bash theme={null} export GRID_BASE_URL="https://api.lightspark.com/grid/2025-10-13" export GRID_CLIENT_ID="YOUR_SANDBOX_CLIENT_ID" export GRID_CLIENT_SECRET="YOUR_SANDBOX_CLIENT_SECRET" ``` ## Walkthrough The walkthrough below is the happy path: create a customer, find the auto-provisioned account and its default email OTP credential, fund it, and withdraw to a bank account. Each step shows the HTTP request your integrator backend makes on behalf of the client. ### 1. Create a customer Create the customer record. A Global Account is provisioned automatically whenever a customer is created on a platform that has `USDB` in its supported currencies — you don't need to pass it on the customer. ```bash theme={null} curl -X POST "$GRID_BASE_URL/customers" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerType": "INDIVIDUAL", "platformCustomerId": "ind-9f84e0c2", "region": "US", "email": "jane@example.com", "fullName": "Jane Doe", "birthDate": "1990-01-15", "nationality": "US" }' ``` **Response:** `201 Created` with the new `Customer:...` id. In sandbox, the customer is KYC-approved immediately; in production you would now run them through the KYC / KYB flow before any funds can move. ### 2. Find the Global Account When a customer is created on a USDB-enabled platform, Grid automatically provisions a Global Account alongside their other internal accounts. Fetch it by filtering the customer's internal accounts by `type=EMBEDDED_WALLET`. ```bash theme={null} curl -X GET "$GRID_BASE_URL/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001&type=EMBEDDED_WALLET" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response:** ```json theme={null} { "data": [ { "id": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "type": "EMBEDDED_WALLET", "balance": { "amount": 0, "currency": { "code": "USDB", "name": "USDB", "decimals": 6 } }, "totalBalance": { "amount": 0, "currency": { "code": "USDB", "name": "USDB", "decimals": 6 } }, "fundingPaymentInstructions": [], "createdAt": "2026-04-19T12:00:00Z", "updatedAt": "2026-04-19T12:00:00Z" } ], "hasMore": false, "totalCount": 1 } ``` Hold onto the `InternalAccount:...` id — every auth credential is scoped to it. ### 3. Find the default email OTP credential Global Accounts are initialized with an `EMAIL_OTP` credential tied to the customer email on file. Fetch the auth methods for the account and keep the `AuthMethod:...` id for the signing step later in this walkthrough. ```bash theme={null} curl -X GET "$GRID_BASE_URL/auth/credentials?accountId=InternalAccount:019542f5-b3e7-1d02-0000-000000000002" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response:** ```json theme={null} { "data": [ { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000001", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-19T12:00:01Z", "updatedAt": "2026-04-19T12:00:01Z" } ] } ``` You can add passkeys or OAuth credentials later, but adding credentials is itself a signed action. Start with the default email OTP credential to mint the first session signing key. ### 4. Fund the Account Global Accounts behave like any other internal account on the way in — incoming funds do not need the customer's signature. In sandbox, use the sandbox funding endpoint to skip straight to a funded state: ```bash theme={null} curl -X POST "$GRID_BASE_URL/sandbox/internal-accounts/InternalAccount:019542f5-b3e7-1d02-0000-000000000002/fund" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "amount": 1000000000 }' ``` `amount` is in the smallest unit of the account's currency. USDB has 6 decimals, so `1000000000` is 1,000.00 USDB. You will receive an `INCOMING_PAYMENT` webhook when the balance updates. The account now holds 1,000.00 USDB. > [!TIP] > > To fund from another currency (USD ACH, USDC on-chain, etc.), create a quote with `destination.destinationType: "ACCOUNT"` pointing at the Global Account's `InternalAccount` id. The quote's `sourceCurrency` can be any supported platform currency; Grid will convert into USDB on execute. ### 5. Add an external bank account Add the destination the customer wants to withdraw to. This is a standard external account — nothing Global Account-specific. ```bash theme={null} curl -X POST "$GRID_BASE_URL/customers/external-accounts" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "currency": "USD", "platformAccountId": "jane_doe_checking", "accountInfo": { "accountType": "USD_ACCOUNT", "accountNumber": "1234567890", "routingNumber": "021000021", "bankAccountType": "CHECKING", "beneficiary": { "beneficiaryType": "INDIVIDUAL", "fullName": "Jane Doe", "birthDate": "1990-01-15", "nationality": "US", "address": { "line1": "123 Main Street", "city": "San Francisco", "state": "CA", "postalCode": "94105", "country": "US" } } } }' ``` **Response:** `201 Created` with the new `ExternalAccount:...` id. ### 6. Create a withdrawal quote Create a quote with the Global Account as the source. Grid returns a `payloadToSign` in the quote's payment instructions — this is what the client will sign to authorize the transfer. ```bash theme={null} curl -X POST "$GRID_BASE_URL/quotes" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 10000000, "description": "Withdrawal to checking" }' ``` `lockedCurrencyAmount` is in the smallest unit of the locked side's currency. Here the sending currency is USDB (6 decimals), so `10000000` is 10.00 USDB. **Response:** ```json theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000006", "status": "PENDING", "createdAt": "2026-04-19T12:05:00Z", "expiresAt": "2026-04-19T12:10:00Z", "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "sendingCurrency": { "code": "USDB", "name": "USDB", "decimals": 6 }, "receivingCurrency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 }, "totalSendingAmount": 10000000, "totalReceivingAmount": 975, "exchangeRate": 1.0, "feesIncluded": 250000, "platformFeesIncluded": 0, "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000005", "paymentInstructions": [ { "accountOrWalletInfo": { "accountType": "EMBEDDED_WALLET", "payloadToSign": "{\"type\":\"ACTIVITY_TYPE_SIGN_TRANSACTION_V2\",\"timestampMs\":\"1746736509954\",\"organizationId\":\"org_abc123\",\"parameters\":{\"signWith\":\"wallet_abc123def456\",\"unsignedTransaction\":\"ea69b4bf05f775209f26ff0a34a05569180f7936579d5c4af9377ae550194f72\",\"type\":\"TRANSACTION_TYPE_ETHEREUM\"},\"generateAppProofs\":true}" }, "instructionsNotes": "Stamp the payloadToSign byte-for-byte and pass the stamp as the Grid-Wallet-Signature header on execute" } ] } ``` ### 7. Authenticate and sign The customer has an outstanding quote with a `payloadToSign`. Now we need a session signing key to sign it with. With `EMAIL_OTP`, the client generates a TEK (Target Encryption Key) pair, HPKE-encrypts the OTP code, and uses the TEK private key both to complete login and to sign the quote payload. **1. Your backend requests a fresh OTP** Ask Grid to send a fresh OTP email for the default `EMAIL_OTP` credential. The response includes `otpEncryptionTargetBundle` for the secure OTP flow. ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/challenge" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (200):** ```json theme={null} { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000001", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "otpEncryptionTargetBundle": "{\"version\":\"v1.0.0\",\"data\":\"7b22...\",\"dataSignature\":\"3045...\",\"enclaveQuorumPublic\":\"04a1...\"}", "createdAt": "2026-04-19T12:00:01Z", "updatedAt": "2026-04-19T12:05:00Z" } ``` Return `otpEncryptionTargetBundle` to the client. **2. Client encrypts the OTP and verifies** The client generates a fresh [P-256 key pair (the TEK)](client-keys#1-generate-a-client-key-pair), [HPKE-encrypts](client-keys#encrypt-the-otp-code-email_otp-only) `{otp_code, public_key}` under `otpEncryptionTargetBundle`, and sends the encrypted bundle to your backend. `public_key` must be the compressed public key (`02` or `03` prefix). In sandbox, use OTP code `000000`. Your backend calls verify with the encrypted bundle: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "EMAIL_OTP", "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" }' ``` **Response (202):** ```json theme={null} { "type": "EMAIL_OTP", "payloadToSign": "{\"login\":{\"publicKey\":\"02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31\"},\"tokenId\":\"7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21\",\"type\":\"USAGE_TYPE_LOGIN\"}", "requestId": "Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21", "expiresAt": "2026-04-19T12:10:00Z" } ``` Return `payloadToSign` and `requestId` to the client. **3. Client stamps payloadToSign and completes login** The client [stamps](client-keys#2-sign-a-payloadtosign) the exact UTF-8 bytes of `payloadToSign`, unchanged, with the TEK private key and sends the stamp back to your backend. Your backend retries the same request with the stamp: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ -d '{ "type": "EMAIL_OTP", "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" }' ``` **Response (200):** ```json theme={null} { "id": "Session:019542f5-b3e7-1d02-0000-000000000003", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-19T12:05:01Z", "updatedAt": "2026-04-19T12:05:01Z", "expiresAt": "2026-04-19T12:20:01Z" } ``` The TEK public key is now the session API key. The TEK private key **is** the session signing key — the client already has it. **4. Client stamps the quote payload** The client [signs the quote's `payloadToSign`](client-keys#2-sign-a-payloadtosign) with the same TEK private key. Return the full Grid wallet signature to your backend. > [!WARNING] > > Stamp the `payloadToSign` bytes exactly as Grid returned them. Do not parse, re-serialize, trim, or normalize the JSON — the stamp must cover the same bytes Grid's verifier hashes. The session signing key is now valid for 15 minutes, so subsequent account actions within that window (for example, a second withdrawal) can reuse it without another `/challenge` + `/verify` round-trip. ### 8. Execute the quote Call `/execute` with the stamp in the `Grid-Wallet-Signature` header. ```bash theme={null} curl -X POST "$GRID_BASE_URL/quotes/Quote:019542f5-b3e7-1d02-0000-000000000006/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: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" ``` **Response:** ```json theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000006", "status": "PROCESSING", "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000005", "totalSendingAmount": 10000000, "totalReceivingAmount": 975, "feesIncluded": 250000 } ``` The transaction is on its way. You'll receive standard transaction webhooks (`OUTGOING_PAYMENT`) as it settles — see [Transaction lifecycle](/platform-overview/core-concepts/transaction-lifecycle). ## Where to next > [card] [Client keys & signing](./client-keys) > > Generate the P-256 key pair, hold the session signing key, and sign payloads on Web, iOS, and Android. > [card] [Authentication](./authentication) > > OAuth and Email OTP flows, passkey reauthentication, and the full WebAuthn parameter mapping. > [card] [Sessions](./managing-sessions) > > List, refresh, and revoke active sessions. > [card] [Exporting a wallet](./exporting-wallet) > > Let a customer take their wallet seed off Grid. # Global Accounts Source: https://agents-docs.lightspark.com/global-accounts/index Give your customers a branded, self-custodial multi-currency account powered by Grid. ![Global Accounts hero](https://mintcdn.com/agents-docs/qL8kqlSYbvexR8cg/images/heroes/hero-gga.webp?fit=max\&auto=format\&n=qL8kqlSYbvexR8cg\&q=85\&s=7b06f961ed28d727d2de1d2a120c6dc9) A Global Account is what we consider a programmable, self-custodial account for holding and moving money. Under the hood, it is an embedded [Spark](https://spark.money) wallet that Lightspark provisions for your customer. It can hold different stablecoins, including those denominated in local currencies, as well as other digital assets such as Bitcoin. In the API, a Global Account is an internal account with `type: "EMBEDDED_WALLET"`. It uses the same customer, quote, transaction, and webhook primitives as other accounts. Receiving funds works the same way, too. To move money out, the customer must authorize the transfer on their device, which signs the payment with a session signing key. ## Why a Global Account? * **Self-custodial.** Neither Lightspark nor your platform can move funds unilaterally. Outbound transfers require authorization from the customer's device. * **Stablecoin-denominated.** Balances can be held in stablecoins like [Brale-issued USDB](https://brale.xyz/stablecoins/USDB). You can also issue your own stablecoin and [request enablement in Grid](/api-reference/stablecoins/request-grid-enablement-for-a-stablecoin). Use the `/quotes` API to convert from fiat or withdraw through supported payment rails. * **Native.** Reuse the customer, internal-account, quote, transaction, and webhook primitives you already use for payouts or P2P. Global Accounts add authentication and signing at the account level. ## Payment flow Global Accounts use the same `/quotes` and `/quotes/{id}/execute` pattern as other payments. Outbound transfers also require a client signature. * **Incoming funds.** Funding an account works like any other internal account. Create a quote with the Global Account as the `destination`, execute it, and Grid converts the source currency into USDB and credits the account. Incoming funds do not require customer approval. * **Outgoing funds.** Withdrawals and transfers out require the customer to authorize them on their device. Grid returns a `payloadToSign` in the quote's `paymentInstructions`; the client signs those bytes with its session signing key and passes the base64 signature as the `Grid-Wallet-Signature` header on `/quotes/{id}/execute`. Only then does Grid release the funds. Sessions are short-lived (15 minutes by default) and bound to a specific device via the client key pair, so a stolen signature can't be replayed from a different device or after the session expires. Standard transaction webhooks fire throughout the lifecycle. See [Transaction lifecycle](/platform-overview/core-concepts/transaction-lifecycle). ## Architecture Three parties participate in every signed action: | Party | Role | | - | - | | **Client** | The customer's device (browser, iOS app, or Android app). Generates the client key pair, runs WebAuthn, holds the session signing key, and signs outbound requests. | | **Integrator backend** | Your server. Holds your Grid API credentials, brokers every call to Grid on behalf of the client, and issues WebAuthn challenges for initial passkey registration. | | **Grid** | Verifies auth credentials, issues sessions bound to the client's public key, and enforces that every account action is authorized. | The client **never** talks to Grid directly. Every request flows client → integrator backend → Grid. ## Auth credentials, client keys, and session signing keys Three distinct pieces of crypto collaborate to authorize actions on the Global Account (withdrawals, credential changes, session revocations, wallet exports, and wallet privacy updates): | Piece | Where it lives | How long it lives | What it proves | | - | - | - | - | | **Auth credential**: passkey, OIDC token, or email OTP | Registered on the account; the passkey itself lives on the authenticator, OIDC on your IdP, OTP in the user's inbox | Until the customer revokes it | *"I am the human who owns this account."* Used to authenticate the user at the start of each session. | | **Client key pair** (P-256) | Generated on the client device for each session-issuing or export request; private key stays in device-local secure storage | One authentication, session refresh, or wallet export | Binds a session or wallet export to the exact device that asked for it. For a session, its private key is the session signing key. For a wallet export, Grid encrypts the credentials to its public key, so only this device can decrypt them. | | **Session signing key** (P-256) | The private key of the client key pair that issued the session; held on the device for the session's lifetime | 15 minutes (default) | *"This specific account action was approved on an authenticated device."* Builds Grid wallet signatures over the `payloadToSign` Grid returns on quotes, credential changes, session refresh/revocation, wallet exports, sign-in email or phone number changes, and wallet privacy updates. | The flow is always the same: verify an auth credential with a fresh client key pair → hold its private key as the short-lived session signing key → build a Grid wallet signature over the `payloadToSign` bytes on the client → pass that signature as the `Grid-Wallet-Signature` header on the request that actually moves funds or changes account state. This applies to withdrawals, adding or removing credentials, refreshing or revoking sessions, exporting the wallet seed, changing the sign-in email or phone number, and updating wallet privacy. ## Core capabilities ![](/images/icons/wallet1.svg) Branded dollar accounts Give each customer a branded account experience backed by Grid account infrastructure. ![](/images/icons/coins.svg) Stablecoin balances Hold dollar-denominated value and use Grid quotes to move between account balances and supported rails. ![](/images/icons/globe.svg) Local off-ramps Let customers move value to supported local bank rails, including corridors such as PIX, UPI, SEPA, FPS, and more. ![](/images/icons/shield.svg) Self-custodial authorization Require customer approval for outbound account actions. Grid and your platform cannot unilaterally move customer funds. ![](/images/icons/bitcoin.svg) Built on Spark Use Spark, a Lightning-compatible Bitcoin L2, to support Bitcoin and stablecoin flows where enabled for your platform. ![](/images/icons/bell.svg) Webhooks and reconciliation Track funding, withdrawals, and settlement status with standard Grid account and transaction webhooks. ## Additional capabilities Some Global Accounts capabilities require platform enablement before you can build with them. [Book a demo](https://www.lightspark.com/contact) to see how they fit your platform. ![](/images/icons/credit-card1.svg) Card programs Issue cards tied to Global Account balances where enabled for your platform. ![](/images/icons/agent.svg) Agentic payments Support bounded account access for AI agents with policy-controlled movement. ![](/images/icons/IconSquareChecklistMagnifyingGlass.svg) Advanced account controls Configure limits, permissions, and controls for more complex account programs. ## Where to next > [card] [Implementation overview](/global-accounts/implementation-overview) > > End-to-end walkthrough: create a customer, register a passkey, fund the account, and execute a signed withdrawal. > [card] [Authentication](/global-accounts/authentication) > > Passkey, OAuth (OIDC), and email OTP registration and reauthentication flows. > [card] [Client keys & signing](/global-accounts/client-keys) > > Generate the P-256 key pair, hold the session signing key, and sign payloads on Web, iOS, and Android. > [card] [Sandbox testing](/global-accounts/platform-tools/sandbox-testing) > > The sandbox OTP code, signing with real stamps, and sandbox OIDC token rules that exercise the full request shape without standing up real auth providers. # Sessions Source: https://agents-docs.lightspark.com/global-accounts/managing-sessions List and revoke active sessions on a Global Account Every call to `POST /auth/credentials/{id}/verify` creates a new **session** — an authenticated signing context with a 15-minute lifetime by default. Sessions accumulate: a customer signed in on a laptop and a phone has two active sessions, each with its own session signing key held on that device. Use the session endpoints to show active sign-ins, refresh an active session before it expires, and sign out of a specific device. ## List active sessions ```bash theme={null} curl -X GET "$GRID_BASE_URL/auth/sessions?accountId=InternalAccount:019542f5-b3e7-1d02-0000-000000000002" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (200):** ```json theme={null} { "data": [ { "id": "Session:019542f5-b3e7-1d02-0000-000000000003", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "PASSKEY", "nickname": "iPhone Face-ID", "createdAt": "2026-04-19T12:00:02Z", "updatedAt": "2026-04-19T12:00:02Z", "expiresAt": "2026-04-19T12:15:02Z" }, { "id": "Session:019542f5-b3e7-1d02-0000-000000000007", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-19T10:01:00Z", "updatedAt": "2026-04-19T10:01:00Z", "expiresAt": "2026-04-19T10:16:00Z" } ] } ``` The list endpoint returns all **active** sessions; expired sessions are not included. Session responses never carry the session signing key. The client generated it and already holds it. ## Refresh a session Session refresh issues a new session for a new session signing key, authorized by an existing active session. Generate a fresh client key pair and send its compressed public key in lowercase hex as `clientPublicKey` (see [Generate a client key pair](client-keys#1-generate-a-client-key-pair)). Use this when the customer is still present and the current session is close to expiration. If the session has already expired, reauthenticate with the original credential instead. Refresh uses the same [signed-retry pattern](authentication#the-signed-retry-pattern) as credential management, including the possibility of a [still-processing `200` response](authentication#handling-a-still-processing-response) on the signed retry — re-send the same request until you get the `201` below. **1. First call — receive the challenge** ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/sessions/Session:019542f5-b3e7-1d02-0000-000000000003/refresh" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "clientPublicKey": "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" }' ``` **Response (202):** ```json theme={null} { "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"expirationSeconds\":\"900\",\"publicKey\":\"02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_STAMP_LOGIN\"}", "requestId": "Request:8c1e7f55-7b9c-4383-86c7-0cbde77c7328", "expiresAt": "2026-04-19T12:10:00Z" } ``` **2. Client stamps the payload** Build a Grid wallet signature over `payloadToSign` with the current session signing key. **3. Signed retry — receive the refreshed session** ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/sessions/Session:019542f5-b3e7-1d02-0000-000000000003/refresh" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:8c1e7f55-7b9c-4383-86c7-0cbde77c7328" \ -d '{ "clientPublicKey": "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" }' ``` **Response (201):** the new `AuthSession`. The private key matching the `clientPublicKey` above is now the session signing key, so replace the old one on the client. ## Revoke a session Session revocation uses the same [signed-retry pattern](authentication#the-signed-retry-pattern) as credential management, including a possible [still-processing `200` response](authentication#handling-a-still-processing-response) on the signed retry. Unlike credential revocation, a session **can revoke itself** — this is how self-logout works: sign with the session key you are about to invalidate. **1. First call — receive the challenge** ```bash theme={null} curl -X DELETE "$GRID_BASE_URL/auth/sessions/Session:019542f5-b3e7-1d02-0000-000000000003" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (202):** ```json theme={null} { "type": "PASSKEY", "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"apiKeyIds\":[\"api_key_2m9F...\"],\"userId\":\"user_2m9F...\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_DELETE_API_KEYS\"}", "requestId": "Request:2b1e5a08-9c44-4e91-ae7f-6d0b3f8c1e22", "expiresAt": "2026-04-19T12:10:00Z" } ``` **2. Client stamps the payload** Build a Grid wallet signature over `payloadToSign` with any active session signing key on the same account — either the session being revoked (self-logout) or another session (admin-style sign-out of a different device). **3. Signed retry — session is revoked** ```bash theme={null} curl -X DELETE "$GRID_BASE_URL/auth/sessions/Session:019542f5-b3e7-1d02-0000-000000000003" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:2b1e5a08-9c44-4e91-ae7f-6d0b3f8c1e22" ``` **Response:** `204 No Content`. > [!TIP] > > Revoking a session only invalidates the session signing key, not the credential that issued it. The next call to `POST /auth/credentials/{id}/verify` on that credential still works and issues a brand new session. # Postman Collection Source: https://agents-docs.lightspark.com/global-accounts/platform-tools/postman-collection Explore the Grid API for Global Accounts using the published Postman collection > [!NOTE] > > Use our hosted Postman collection to explore endpoints and send test requests quickly. ![](/images/icons/IconPostman.svg) Open Postman Collection Launch the collection in Postman. [Open](https://www.postman.com/lightspark-dev/lightspark-grid/collection/adligoh/grid-api?action=share\&source=copy-link\&creator=49207478) # Sandbox testing Source: https://agents-docs.lightspark.com/global-accounts/platform-tools/sandbox-testing Exercise the Global Account auth, signing, and funding flows without standing up real OTP delivery, WebAuthn, or OIDC providers The Grid sandbox lets you exercise the full Global Accounts integration — customer creation, account lookup, credential registration, funding, and signed withdrawals — without moving real money or standing up real auth providers. All API endpoints work the same way as in production, but money movements are simulated. OTP uses a fixed sandbox code, passkey assertions and wallet signatures are verified as in production, and OAuth uses JWT-shaped sandbox OIDC tokens with claim, freshness, identity, and nonce checks. ## Sandbox setup 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. In sandbox, customers are KYC-approved on creation, and a Global Account is provisioned automatically alongside any other internal accounts whenever the platform has `USDB` in its supported currencies. ## Funding a Global Account Real Global Accounts are funded by following payment instructions or by executing a quote into the account. In sandbox, you can instantly add USDB to any internal account using the sandbox funding endpoint: ```bash theme={null} curl -X POST "$GRID_BASE_URL/sandbox/internal-accounts/InternalAccount:abc123/fund" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "amount": 100000 }' ``` This credits the account immediately and fires the standard `INCOMING_PAYMENT` webhook. Use this to skip straight to a funded state when you're testing withdrawals. ## 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 stamp the exact returned `payloadToSign` string 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 [Encrypt the OTP code](/global-accounts/client-keys#encrypt-the-otp-code-email_otp-only) 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. **1. Create a WebAuthn credential** Generate your own WebAuthn registration challenge and call `navigator.credentials.create()`. **2. Register the passkey** Register the passkey with `POST /auth/credentials`, passing the challenge and attestation returned by the browser. **3. Request a challenge** Reauthenticate with `POST /auth/credentials/{id}/challenge`, passing the compressed P-256 `clientPublicKey` (lowercase hex) whose private key becomes the session signing key. **4. Run the browser assertion** Pass the returned `challenge` into `navigator.credentials.get()` using the returned `credentialId` in `allowCredentials`. **5. Verify the assertion** Verify with `POST /auth/credentials/{id}/verify`, passing the browser assertion and echoing `Request-Id` from the challenge response. The sandbox validates the registered credential ID, WebAuthn challenge, origin/RP binding, user-presence bit, assertion signature, and signature counter. A successful verify response returns the session, just like production. The private key matching the `clientPublicKey` is the session signing key. ```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": "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" }' # 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": "..." } }' ``` ### 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 the lowercase hex SHA-256 digest of `clientPublicKey`, hashed as the exact string sent in the verify request. That string is the compressed public key in lowercase hex: 66 characters starting with `02` or `03`. ```bash theme={null} export PUBLIC_KEY="02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" OIDC_TOKEN=$(node - <<'NODE' const crypto = require("crypto"); const publicKey = process.env.PUBLIC_KEY || "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31"; 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. ### Wallet signature header For `PASSKEY` and `OAUTH` credentials and session refresh, the private key matching the `clientPublicKey` you supplied is the session signing key. For `EMAIL_OTP`, the TEK private key you generated for the encrypted OTP flow is the session signing key. 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. ```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. ## Webhooks All webhook events fire normally in sandbox. Configure your webhook URL in the dashboard, perform any signed action, and your endpoint receives the same `INCOMING_PAYMENT`, `OUTGOING_PAYMENT`, and account-state events as production. > [!WARNING] > > Do not try to send real money to any sandbox addresses or accounts. These are not real destinations and will not receive funds. ## Moving to production When you're ready to go live: 1. Generate production API tokens in the dashboard and swap them for the sandbox credentials in your environment. 2. Remove sandbox magic values and unsigned sandbox OIDC tokens from your client and server code — production runs the real OTP, HPKE, WebAuthn, OIDC signature, and ECDSA flows. 3. Configure production webhook endpoints. 4. Test with small amounts first. ## Next steps * [Implementation overview](/global-accounts/implementation-overview) — end-to-end walkthrough of the happy path. * [Authentication](/global-accounts/authentication) — per-credential-type registration and reauthentication flows. * [Webhooks](/global-accounts/platform-tools/webhooks) — event handling reference. # Webhooks Source: https://agents-docs.lightspark.com/global-accounts/platform-tools/webhooks Receive webhook events for funding, withdrawals, and other account state changes on Global Accounts All webhooks sent by the Grid API include a signature in the `X-Grid-Signature` header, which allows you to verify the authenticity of the webhook. This is critical for security, as it ensures that only legitimate webhooks from Grid are processed by your system. ## Signature Verification Process 1. **Obtain your Grid public key** * This is available in your [dashboard](https://app.lightspark.com) under Developers webhook settings. * The key is in PEM format and can be used with standard cryptographic libraries 2. **Verify incoming webhooks** * Extract the signature from the `X-Grid-Signature` header * Decode the base64 signature * Create a SHA-256 hash of the entire request body * Verify the signature using the Grid webhook public key and the hash * Only process the webhook if the signature verification succeeds ## Verification Examples ### Node.js Example ```javascript theme={null} const crypto = require('crypto'); const express = require('express'); const app = express(); // Your Grid public key provided during integration const GRID_WEBHOOK_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE... -----END PUBLIC KEY-----`; app.post('/webhooks/uma', (req, res) => { const signatureHeader = req.header('X-Grid-Signature'); if (!signatureHeader) { return res.status(401).json({ error: 'Signature missing' }); } try { let signature: Buffer; try { // Parse the signature as JSON. It's in the format {"v": "1", "s": "base64_signature"} const signatureObj = JSON.parse(signatureHeader); if (signatureObj.v && signatureObj.s) { // The signature is in the 's' field signature = Buffer.from(signatureObj.s, "base64"); } else { throw new Error("Invalid JSON signature format"); } } catch { // If JSON parsing fails, treat as direct base64 signature = Buffer.from(signatureHeader, "base64"); } // Create verifier with the public key and correct algorithm const verifier = crypto.createVerify("SHA256"); const payload = await request.text(); verifier.update(payload); verifier.end(); // Verify the signature using the webhook public key const isValid = verifier.verify( { key: GRID_WEBHOOK_PUBLIC_KEY, format: "pem", type: "spki", }, signature, ); if (!isValid) { return res.status(401).json({ error: 'Invalid signature' }); } // Webhook is verified, process it based on type const webhookData = req.body; if (webhookData.type.startsWith('INCOMING_PAYMENT.')) { // Process incoming payment webhook // ... } else if (webhookData.type.startsWith('OUTGOING_PAYMENT.')) { // Process outgoing payment webhook // ... } // Acknowledge receipt of the webhook return res.status(200).json({ received: true }); } catch (error) { console.error('Signature verification error:', error); return res.status(401).json({ error: 'Signature verification failed' }); } }); app.listen(3000, () => { console.log('Webhook server listening on port 3000'); }); ``` ### Python Example ```python theme={null} from cryptography.hazmat.primitives import serialization, hashes from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature from flask import Flask, request, jsonify import base64 app = Flask(__name__) # Your Grid public key provided during integration GRID_PUBLIC_KEY = """-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE... -----END PUBLIC KEY-----""" # Load the public key public_key = serialization.load_pem_public_key( GRID_PUBLIC_KEY.encode('utf-8') ) @app.route('/webhooks/uma', methods=['POST']) def handle_webhook(): # Get signature from header signature = request.headers.get('X-Grid-Signature') if not signature: return jsonify({'error': 'Signature missing'}), 401 try: # Get the raw request body request_body = request.get_data() # Create a SHA-256 hash of the request body hash_obj = hashes.Hash(hashes.SHA256()) hash_obj.update(request_body) digest = hash_obj.finalize() # Decode the base64 signature signature_bytes = base64.b64decode(signature) # Verify the signature try: public_key.verify( signature_bytes, request_body, ec.ECDSA(hashes.SHA256()) ) except Exception as e: return jsonify({'error': 'Invalid signature'}), 401 # Webhook is verified, process it based on type webhook_data = request.json if webhook_data['type'].startswith('INCOMING_PAYMENT.'): # Process incoming payment webhook # ... pass elif webhook_data['type'].startswith('OUTGOING_PAYMENT.'): # Process outgoing payment webhook # ... pass # Acknowledge receipt of the webhook return jsonify({'received': True}), 200 except Exception as e: print(f'Signature verification error: {e}') return jsonify({'error': 'Signature verification failed'}), 401 if __name__ == '__main__': app.run(port=3000) ``` ## Testing To test your webhook implementation, you can trigger a test webhook from the Grid dashboard. This will send a test webhook to the endpoint you provided during the integration process. The test webhook will also be sent automatically when you update your platform configuration with a new webhook URL. An example of the test webhook payload is shown below: ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000007", "type": "TEST", "timestamp": "2025-08-15T14:32:00Z", "data": {} } ``` You should verify the signature of the webhook using the Grid public key and the process outlined in the [Signature Verification Process](#signature-verification-process) section and then reply with a 200 OK response to acknowledge receipt of the webhook. ## Security Considerations * **Always verify signatures**: Never process webhooks without verifying their signatures. * **Use HTTPS**: Ensure your webhook endpoint uses HTTPS to prevent man-in-the-middle attacks. * **Implement idempotency**: Use the `id` field to prevent processing duplicate webhooks. * **Timeout handling**: Implement proper timeout handling and respond to webhooks promptly. ## Retry Policy The Grid API will retry webhooks with the following policy based on the webhook type: | Webhook Type | Retry Policy | Notes | | - | - | - | | `TEST` | No retries | Used for testing webhook configuration | | `OUTGOING_PAYMENT.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `INCOMING_PAYMENT.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on: 409 (duplicate webhook) or PENDING status since it is served as an approval mechanism in-flow | | `BULK_UPLOAD.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `WALLET_OPERATION.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `INVITATION.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CUSTOMER.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `ACCOUNT.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CARD.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CARD_TRANSACTION.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CARD_AUTHORIZATION.REQUEST` | No retries | Real-time decision request, sent only while `cardConfigs.realTimeAuthorization` is set | # Implementation Overview Source: https://agents-docs.lightspark.com/global-p2p/getting-started/implementation-overview High-level implementation plan for configuring, onboarding, funding, and moving money This page gives you a 10,000‑ft view of an end‑to‑end implementation. It is intentionally generalized because the flow supports multiple customer types and external account types (e.g., `USD_ACCOUNT`, `EUR_ACCOUNT`, `MXN_ACCOUNT`, `INR_ACCOUNT`). The detailed guides that follow provide concrete fields, edge cases, and step‑by‑step instructions. ## Platform configuration Configure your platform once before building user flows. * Set your UMA domain and brand details used for UMA addressing * Configure required counterparty and compliance fields for your target corridors * Provide webhook endpoints for outgoing and incoming payment notifications * Generate API credentials for Sandbox (and later Production) * Review regional capabilities (rails, currencies, settlement windows) ## Customer creation and onboarding Onboard customers and assign UMA addresses if appropriate. There are two patterns: * Regulated entities can directly create customers by providing KYC/KYB data via API * Unregulated entities should request a KYC link and embed the hosted KYC flow; once completed, the customer can transact * Store platform customer IDs and UMA handles for use in payment flows ## Account funding Choose how transactions are funded based on your product design and region. * Prefunded: Maintain balances in one or more currencies and spend from those balances * Just‑in‑time (JIT): Create a quote and fund it in real time using the payment instructions provided; ideal when you don’t wish to hold float > [!TIP] > > You can mix models: keep small operational float for common corridors and use JIT for long tail routes. ## External account creation (payout destinations) Register payout accounts your customers will send to (or receive from), such as CLABE (MX), IBAN (EU/UK), US accounts, UPI (IN), and others. * Capture beneficiary details (individual or business) and required banking fields * Validate account formats where applicable and map them to your internal customer ## Sending payments Sending consists of lookup, pricing, funding, and execution. * Resolve the counterparty: look up receiver information (UMA or bank account) for compliance review and to determine capabilities * Create a quote: specify source/destination, currencies, and whether you lock sending or receiving amount; receive exchange rate, limits, fees, and (for JIT) funding instructions * Fund and execute: for prefunded, confirm/execute; for JIT, push funds exactly as instructed (amount, reference) and the platform handles FX and delivery * Observe status via webhooks and surface outcomes in your UI > [!NOTE] > > When sending UMA payments, the sender can retrieve counterparty information before initiating to support compliance and risk checks. ## Receiving payments Enable customers to receive funds to their UMA or linked bank account. * Expose customer UMA/addressing to payers * The platform handles conversion and offramping to the receiver’s account currency * Approve or auto‑approve per your policy; update balances on completion via webhooks ## Reconciling transactions Implement operational processes to keep your ledger in sync. * Process webhooks idempotently; map statuses (pending, processing, completed, failed) * Tie transactions back to quotes and customers; persist references * Produce statements and audit trails; handle refunds, retries, and dispute flows where applicable ## Testing in Sandbox Use Sandbox to build and validate end‑to‑end without moving real funds. * Exercise receiver lookup, quote creation, funding instructions, and webhook lifecycles * Validate compliance decisioning with realistic but synthetic data * Optionally use the Test Wallet as a counterparty for faster iteration (see Tools) ## Enabling Production When you’re ready to go live: * Complete corridor and provider onboarding as needed for your regions * Confirm webhook security, monitoring, and alerting are in place * Review rate limits, error handling, retries, and idempotency keys * Run final UAT in Sandbox, then request Production access from your Lightspark contact > [!TIP] > > Contact your Lightspark representative to enable Production and finalize corridor activations. ## Contact us If you need assistance with the Grid API, [contact our team](https://www.lightspark.com/contact). # Platform Configuration Source: https://agents-docs.lightspark.com/global-p2p/getting-started/platform-configuration Configuring credentials, webhooks and currencies for your platform global P2P payments ## Supported currencies During onboarding, choose the currencies your platform will support. For prefunded models, Grid automatically creates per‑currency accounts for each new customer. You can add or remove supported currencies anytime in the Grid dashboard. ## API credentials and authentication Create API credentials in the Grid dashboard. Credentials are scoped to an environment (Sandbox or Production) and cannot be used across environments. * Authentication: Use HTTP Basic Auth with your API key and secret in the `Authorization` header. * Keys: Sandbox keys only work against Sandbox; Production keys only work against Production. > [!TIP] > > Never share or expose your API secret. Rotate credentials periodically and restrict access. ### Example: HTTP Basic Auth in cURL ```bash theme={null} # Using cURL's Basic Auth shorthand (-u): curl -sS -X GET "https://api.lightspark.com/grid/2025-10-13/config" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ## Base API path The base API path is consistent across environments; your credentials determine the environment. Base URL: `https://api.lightspark.com/grid/2025-10-13` (same for Sandbox and Production; your keys select the environment). ## Webhooks and signature verification Configure your webhook endpoint to receive payment lifecycle events. Webhooks use asymmetric (public/private key) signatures; verify each webhook using the Grid public key available in your dashboard. * Expose a public HTTPS endpoint (for development, reverse proxies like ngrok can help). You'll also need to set your webhook endpoint in the Grid dashboard. * When receiving webhooks, verify the `X-Grid-Signature` header against the exact request body using the dashboard-provided public key * Process events idempotently and respond with 2xx on success You can trigger a test delivery from the API to validate your endpoint setup. The public key for verification is shown in the dashboard; rotate and update it when instructed by Lightspark. ### Test your webhook endpoint Use the webhook test endpoint to send a synthetic event to your configured endpoint. ```bash theme={null} curl -sS -X POST "https://api.lightspark.com/grid/2025-10-13/webhooks/test" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Example test webhook payload: ```json theme={null} { "test": true, "timestamp": "2023-08-15T14:32:00Z", "webhookId": "Webhook:019542f5-b3e7-1d02-0000-000000000001", "type": "TEST" } ``` For more details about webhooks like retry policy and examples, take a look at our Webhooks documentation. ## UMA configuration (optional) To send and receive using UMA with your own domain (e.g., `$alice@yourdomain.com`), configure the following: 1. Configure your UMA domain 2. Proxy inbound UMA requests to Grid 3. Define supported currencies and, if you are a regulated institution, the counterparty information you require If you do not configure an UMA domain, Grid will use the default domain `docs.lightspark.com`. > [!NOTE] > > You can find more information about the UMA protocol and end user experience at [https://uma.me](https://uma.me). ### UMA domain The `umaDomain` parameter defines the domain part of all UMA addresses for your users. For example, if you set `umaDomain` to `mycompany.com`, your users' UMA addresses will follow the format `$username@mycompany.com`. ### Configure UMA proxy requests Set up proxying so UMA‑related requests are forwarded to your assigned `proxyUmaSubdomain`. * UMA domain determines the address format (e.g., `$alice@yourdomain.com`) * Proxy the following paths to `{proxyUmaSubdomain}`: * `https:///.well-known/lnurlp/*` -> `https://.grid.lightspark.com/.well-known/lnurlp/*` * `https:///.well-known/lnurlpubkey` -> `https://.grid.lightspark.com/.well-known/lnurlpubkey` * `https:///.well-known/uma-configuration` -> `https://.grid.lightspark.com/.well-known/uma-configuration` Additionally, configure: * Supported currencies (min/max, enabled transaction types) * Required counterparty fields per currency for compliance screening ### UMA supported currencies Define per‑currency rules for your UMA flows. Each entry can include: * `currencyCode`: (String, required) The ISO 4217 currency code (e.g., "USD"). * `minAmount`: (Integer, required) Minimum transaction amount in the smallest unit of the currency. * `maxAmount`: (Integer, required) Maximum transaction amount in the smallest unit of the currency. * `requiredCounterpartyFields`: (Array, required) For regulated entities, defines PII your platform requires about *external counterparties* for transactions in this currency. * `providerRequiredCustomerFields`: (Array, read-only) For regulated entities, lists user info field names (from `UserInfoFieldName`) that the UMA provider mandates for *your own users* to transact in this currency. This impacts user creation/updates. ### Required counterparty fields For regulated entities, within each currency defined in `supportedCurrencies`, the `requiredCounterpartyFields` parameter allows you to specify what information your platform needs to collect from *external counterparties* (senders or receivers) involved in transactions with your users for that specific currency. Available counterparty fields (to be specified with a `name` and `mandatory` flag): | Field Name (type `UserInfoFieldName`) | Description | | - | - | | `FULL_NAME` | Full legal name of the individual or business | | `BIRTH_DATE` | Date of birth in YYYY-MM-DD format (for individuals) | | `NATIONALITY` | Nationality of the individual | | `ADDRESS` | Physical address including country, city, etc. | | `PHONE_NUMBER` | Contact phone number including country code | | `EMAIL` | Email address | | `BUSINESS_NAME` | Legal business name (for business entities) | | `TAX_ID` | Tax identification number | Each field in `requiredCounterpartyFields` is an object containing: * `name`: The `UserInfoFieldName` representing the PII you require. * `mandatory`: A boolean (true/false) indicating if this field is strictly required by your platform for transactions in this currency. This information will be provided to your platform via webhooks for pending payments, allowing you to screen the counterparty based on your compliance rules before approving the payment. ### UMA provider required user fields For regulated financial institutions, the `providerRequiredCustomerFields` array (per currency) lists the user fields required by the underlying provider. This array is read‑only and informs what you must capture for your own users. This list specifies which user information fields are mandated by the underlying UMA provider for *your own registered users* if they intend to send or receive payments in that particular currency. For example, to allow a user to transact in "USD", the UMA provider might require that the user has a `NATIONALITY` on record. These fields must be supplied when creating or updating a user via the `POST /customers` or `PATCH /customers/{customerId}` endpoints if that user is expected to use the specified currency. Refer to the [Configuring Customers](/global-p2p/onboarding/configuring-customers) guide for more details on how this impacts user setup. ## Manage configuration via API If you prefer to manage settings programmatically, use the `/config` endpoints. ### Retrieve current configuration You can retrieve your current platform configuration to see what settings are already in place: ```bash theme={null} curl -sS -X GET "https://api.lightspark.com/grid/2025-10-13/config" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Response example: ```json theme={null} { "id": "PlatformConfig:019542f5-b3e7-1d02-0000-000000000003", "umaDomain": "example.com", "webhookEndpoint": "https://api.example.com/webhooks/uma", "supportedCurrencies": [ { "currencyCode": "USD", "minAmount": 100, "maxAmount": 1000000, "requiredCounterpartyFields": [ { "name": "FULL_NAME", "mandatory": true }, { "name": "BIRTH_DATE", "mandatory": true } ], "providerRequiredCustomerFields": [ "NATIONALITY", "FULL_NAME" ] } ], "createdAt": "2023-06-15T12:30:45Z", "updatedAt": "2023-07-01T10:00:00Z" } ``` If this is your first time configuring the platform, some default values may be returned which were set up when you first created your account. ### Update platform configuration To update your platform configuration, call the PATCH endpoint with the fields you want to change: ```bash theme={null} curl -sS -X PATCH "https://api.lightspark.com/grid/2025-10-13/config" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "umaDomain": "mycompany.com", "webhookEndpoint": "https://api.mycompany.com/webhooks/uma", "supportedCurrencies": [ { "currencyCode": "USD", "minAmount": 100, "maxAmount": 1000000, "requiredCounterpartyFields": [ { "name": "FULL_NAME", "mandatory": true }, { "name": "BIRTH_DATE", "mandatory": true }, { "name": "ADDRESS", "mandatory": false } ] } ] }' ``` Response: ```json theme={null} { "id": "PlatformConfig:019542f5-b3e7-1d02-0000-000000000003", "umaDomain": "mycompany.com", "webhookEndpoint": "https://api.mycompany.com/webhooks/uma", "supportedCurrencies": [ { "currencyCode": "USD", "minAmount": 100, "maxAmount": 1000000, "requiredCounterpartyFields": [ { "name": "FULL_NAME", "mandatory": true }, { "name": "BIRTH_DATE", "mandatory": true }, { "name": "ADDRESS", "mandatory": false } ], "providerRequiredCustomerFields": [ "NATIONALITY", "FULL_NAME" ] } ], "createdAt": "2023-06-15T12:30:45Z", "updatedAt": "2023-06-15T12:30:45Z" } ``` ## Verify Configuration After updating your configuration, it's recommended to verify that the changes were saved correctly: ```bash theme={null} curl -X GET "https://api.lightspark.com/grid/2025-10-13/config" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` The response should reflect your updated **settings**. # Authentication Source: https://agents-docs.lightspark.com/global-p2p/global-accounts/authentication Register, reauthenticate, and manage Global Account credentials (passkey, OAuth, email OTP) Every Global Account action beyond receiving funds must be authorized by a session signing key. Sessions are issued by verifying one of four **credential types** on the account: | Type | When to use it | | - | - | | **`PASSKEY`** | Best default. Biometric, phishing-resistant, usable across the user's devices via iCloud Keychain / Google Password Manager. | | **`OAUTH`** | Your platform already authenticates the user via OIDC (Google, Apple, your own IdP) and you want Grid to trust the same identity. | | **`EMAIL_OTP`** | Lowest-friction option. Works on any device with email access — no biometric hardware, identity provider, or client SDK required beyond the code entry field. | | **`SMS_OTP`** | Same flow as `EMAIL_OTP`, but delivered to the user's phone number instead of email. Useful when phone is the primary contact channel. | A single internal account can hold one `EMAIL_OTP` credential, one `SMS_OTP` credential, and multiple distinct `PASSKEY` credentials concurrently. `OAUTH` credentials can be added for each supported provider identity. ## Registration vs. verification Global Accounts are initialized with an `EMAIL_OTP` credential tied to the customer email on the internal account. Use that credential to mint the first session, or use an already-registered `OAUTH` or `PASSKEY` credential after you add one. To produce a session: * **`EMAIL_OTP`** — if you need a fresh OTP or encryption target, first call **`POST /auth/credentials/{id}/challenge`**. Generate a P-256 client key pair, HPKE-encrypt the OTP value plus the public key into `encryptedOtpBundle`, then call **`POST /auth/credentials/{id}/verify`**. Grid returns a `202` signed-retry challenge; stamp its `payloadToSign` with the same private key and retry with `Grid-Wallet-Signature` plus `Request-Id`. The signed retry returns the session; the private key you generated is the session signing key. * **`OAUTH`** — call **`POST /auth/credentials/{id}/verify`** with a fresh OIDC token plus `clientPublicKey`. The response is the session; the private key matching `clientPublicKey` is the session signing key. * **`PASSKEY`** — call **`POST /auth/credentials/{id}/challenge`** with your `clientPublicKey` to receive a Grid-issued `challenge` and `requestId`, UTF-8 encode the challenge string for `navigator.credentials.get()`, then call **`POST /auth/credentials/{id}/verify`** with the resulting assertion and the `Request-Id` header. Grid bakes the `clientPublicKey` from the `/challenge` call into the session-creation payload, so it does **not** appear on the `/verify` body. To add another credential, call **`POST /auth/credentials`** with the new credential details. Because the account already has an `EMAIL_OTP` credential, Grid returns a `202` signed-retry challenge. Stamp that `payloadToSign` with an active session signing key, then retry the same request with `Grid-Wallet-Signature` and `Request-Id`. The signed retry returns the new `AuthMethod`. Re-authentication after a session expires skips the original `POST /auth/credentials` create call but otherwise follows the same per-type pattern. `EMAIL_OTP` re-auth needs a fresh OTP email, so call `POST /auth/credentials/{id}/challenge` first; `PASSKEY` re-auth uses the same `/challenge` (with `clientPublicKey`) → `/verify` two-step as the first authentication; `OAUTH` is the exception — since proof of control is a fresh OIDC token, there's nothing to pre-issue, so `/verify` alone suffices. ## Passkey ### Passkey registration Passkey registration spans four parties: the **client** (browser or app), your **integrator backend**, **Grid**, and the platform authenticator (Touch ID / Face ID / Windows Hello / a security key). Your backend issues the WebAuthn registration challenge. Registering the passkey on Grid is an additional-credential signed-retry flow, authorized by an active session from an existing credential. The first authentication challenge is requested explicitly via `POST /auth/credentials/{id}/challenge` — same call shape used for every subsequent reauthentication. ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid participant A as Authenticator C->>IB: Start registration IB->>IB: Generate random challenge (stored in session) IB-->>C: { challenge, rpId, user } C->>A: navigator.credentials.create({ challenge, … }) A-->>C: attestation (credentialId, clientDataJson, attestationObject) C->>IB: POST /my-backend/passkey/register (attestation) IB->>G: POST /auth/credentials { type: PASSKEY, challenge, attestation, … } G-->>IB: 202 { payloadToSign, requestId, expiresAt } IB-->>C: { payloadToSign, requestId } C->>C: stamp(payloadToSign, existingSessionPrivateKey) C->>IB: { stamp, requestId } IB->>G: Same POST
          Grid-Wallet-Signature: stamp
          Request-Id: requestId G-->>IB: 201 AuthMethod { id, type, accountId, … } IB-->>C: { credentialId } C->>C: generateClientKeyPair() C->>IB: POST /my-backend/passkey/challenge (clientPublicKey) IB->>G: POST /auth/credentials/{id}/challenge
          { clientPublicKey } G-->>IB: 200 PasskeyAuthChallenge { challenge, requestId, expiresAt } IB-->>C: { challenge, requestId } C->>A: navigator.credentials.get({ challenge, … }) A-->>C: assertion (credentialId, clientDataJson, authenticatorData, signature) C->>IB: POST /my-backend/passkey/verify (assertion, requestId) IB->>G: POST /auth/credentials/{id}/verify
          Request-Id: requestId
          { type: PASSKEY, assertion } G-->>IB: 200 AuthSession { id, expiresAt } IB-->>C: { sessionId, expiresAt } C->>C: hold the client private key as the session signing key ``` The `challenge` on `POST /auth/credentials` is the one your backend issued for WebAuthn registration. The signed-retry `payloadToSign` is the registration payload that the customer's current session authorizes. The challenge used for the WebAuthn assertion comes later from `POST /auth/credentials/{id}/challenge` — Grid bakes the `clientPublicKey` you send there into the session-creation payload, so the resulting session belongs to the key the device generated. Because that key plumbing happens on `/challenge`, the subsequent `/verify` call carries only the assertion (plus the `Request-Id` header). > [!TIP] > > Passkeys are domain-bound. Before shipping, set up your `/.well-known/apple-app-site-association` and `/.well-known/assetlinks.json` entries so the platform authenticator binds the passkey to your origin and app bundles: > > * Web: [passkeys.dev bootstrapping guide](https://passkeys.dev/docs/use-cases/bootstrapping/#opting-the-user-into-passkeys) > * Android: [Create passkeys on Android](https://developer.android.com/identity/passkeys/create-passkeys) > * iOS: [Supporting passkeys](https://developer.apple.com/documentation/authenticationservices/supporting-passkeys) #### Client sample code The client never talks to Grid. It talks to your integrator backend — the snippets below simulate that call with `fetch('/my-backend/...')`, which your backend then relays to Grid. ```typescript Web (TypeScript) theme={null} // 1. Ask your backend for a registration challenge. const startRes = await fetch("/my-backend/passkey/register/start", { method: "POST", credentials: "include", }); const { challenge, rpId, user } = await startRes.json(); // 2. Ask the authenticator to create a passkey. const attestation = (await navigator.credentials.create({ publicKey: { challenge: base64urlToBytes(challenge), rp: { id: rpId, name: "Acme Wallet" }, user: { id: base64urlToBytes(user.id), name: user.email, displayName: user.displayName, }, pubKeyCredParams: [{ type: "public-key", alg: -7 }], // ES256 authenticatorSelection: { residentKey: "required", userVerification: "required" }, timeout: 60_000, }, })) as PublicKeyCredential; const att = attestation.response as AuthenticatorAttestationResponse; // 3. Send the attestation to your backend, which starts POST /auth/credentials. const registerRes = await fetch("/my-backend/passkey/register", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nickname: "This device", credentialId: bytesToBase64url(new Uint8Array(attestation.rawId)), clientDataJson: bytesToBase64url(new Uint8Array(att.clientDataJSON)), attestationObject: bytesToBase64url(new Uint8Array(att.attestationObject)), transports: att.getTransports?.() ?? [], }), }); const { payloadToSign, requestId: registrationRequestId } = await registerRes.json(); // 4. Stamp the registration payload with an existing session signing key, then // complete the signed retry. The backend retries the same Grid request with // Grid-Wallet-Signature and Request-Id. const gridWalletSignature = await buildGridWalletSignature( existingSessionPrivateKeyBytes, payloadToSign, ); const registerCompleteRes = await fetch("/my-backend/passkey/register/complete", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requestId: registrationRequestId, gridWalletSignature }), }); const { credentialId } = await registerCompleteRes.json(); // 5. Generate the client key pair and ask Grid for an authentication challenge // bound to its public key. const { privateKey, publicKeyHex } = generateClientKeyPair(); const challengeRes = await fetch("/my-backend/passkey/challenge", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ credentialId, clientPublicKey: publicKeyHex }), }); const { challenge: gridChallenge, requestId: authRequestId } = await challengeRes.json(); // 6. Run the WebAuthn assertion against the Grid-issued challenge. This // challenge is a lowercase hex string; UTF-8 encode it exactly as returned. const assertion = (await navigator.credentials.get({ publicKey: { challenge: new TextEncoder().encode(gridChallenge), rpId, userVerification: "required", allowCredentials: [{ type: "public-key", id: base64urlToBytes(credentialId) }], }, })) as PublicKeyCredential; const asr = assertion.response as AuthenticatorAssertionResponse; // 7. Send the assertion to your backend; it relays to POST /verify with Request-Id. const verifyRes = await fetch("/my-backend/passkey/verify", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ credentialId, requestId: authRequestId, assertion: { credentialId: bytesToBase64url(new Uint8Array(assertion.rawId)), clientDataJson: bytesToBase64url(new Uint8Array(asr.clientDataJSON)), authenticatorData: bytesToBase64url(new Uint8Array(asr.authenticatorData)), signature: bytesToBase64url(new Uint8Array(asr.signature)), userHandle: asr.userHandle ? bytesToBase64url(new Uint8Array(asr.userHandle)) : null, }, }), }); const { sessionId, expiresAt } = await verifyRes.json(); // privateKey is now the session signing key until expiresAt — see client-keys.mdx. ``` ```kotlin Android (Kotlin) theme={null} // Uses Jetpack Credential Manager. // implementation("androidx.credentials:credentials:1.3.0") // implementation("androidx.credentials:credentials-play-services-auth:1.3.0") import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.CredentialManager import androidx.credentials.GetCredentialRequest import androidx.credentials.GetPublicKeyCredentialOption import androidx.credentials.PublicKeyCredential suspend fun registerPasskey(context: Context, api: MyBackendApi): EmbeddedWalletSession { // 1. Backend → WebAuthn registration options JSON (challenge, rp, user, ...). val createOptionsJson = api.passkeyRegisterStart() val cm = CredentialManager.create(context) // 2. Authenticator creates the passkey. val createResp = cm.createCredential( context = context, request = CreatePublicKeyCredentialRequest(createOptionsJson), ) as androidx.credentials.CreatePublicKeyCredentialResponse val attestationJson = createResp.registrationResponseJson // 3. Backend -> POST /auth/credentials. Returns a signed-retry challenge. val registerChallenge = api.passkeyRegister(attestationJson, nickname = "This device") // 4. Client stamps the registration payload with an existing session key. val gridWalletSignature = buildGridWalletSignature( existingSessionKeys, registerChallenge.payloadToSign, ) val registerResp = api.passkeyRegisterComplete( requestId = registerChallenge.requestId, gridWalletSignature = gridWalletSignature, ) // 5. Generate client key pair, then ask Grid for an authentication challenge // bound to that public key via POST /auth/credentials/{id}/challenge. val clientKeys = generateClientKeyPair(alias = "embedded-wallet-${registerResp.credentialId}") val challengeResp = api.passkeyChallenge( credentialId = registerResp.credentialId, clientPublicKeyHex = clientKeys.publicKeyHex, ) // 6. Run WebAuthn assertion against Grid-issued challenge. The returned // value is a lowercase hex string; UTF-8 encode it to bytes, then put // those bytes into the platform WebAuthn request format. val webAuthnChallenge = base64UrlEncode( challengeResp.challenge.toByteArray(Charsets.UTF_8), ) val getOptionsJson = buildWebAuthnGetOptionsJson( challenge = webAuthnChallenge, rpId = registerResp.rpId, credentialId = registerResp.credentialId, ) val getResp = cm.getCredential( context = context, request = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(getOptionsJson))), ).credential as PublicKeyCredential val assertionJson = getResp.authenticationResponseJson // 7. Backend -> POST /verify with Request-Id. Returns the AuthSession. return api.passkeyVerify( credentialId = registerResp.credentialId, requestId = challengeResp.requestId, assertionJson = assertionJson, ) } ``` ```swift iOS (Swift) theme={null} import AuthenticationServices final class PasskeyCoordinator: NSObject, ASAuthorizationControllerDelegate { private let api: MyBackendApi private var continuation: CheckedContinuation? private var clientKeys: ClientKeyPair? private var pendingCredentialId: String? private var pendingRequestId: String? init(api: MyBackendApi) { self.api = api } func registerPasskey() async throws -> EmbeddedWalletSession { // 1. Backend → WebAuthn registration options. let options = try await api.passkeyRegisterStart() // 2. Authenticator creates the passkey. let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( relyingPartyIdentifier: options.rpId, ) let request = provider.createCredentialRegistrationRequest( challenge: options.challenge, name: options.user.name, userID: options.user.id, ) let attestation = try await withCheckedThrowingContinuation { (cont: CheckedContinuation< ASAuthorizationPlatformPublicKeyCredentialRegistration, Error >) in let controller = ASAuthorizationController(authorizationRequests: [request]) controller.delegate = AttestationDelegate(cont: cont) controller.performRequests() } // 3. Backend -> POST /auth/credentials. Returns a signed-retry challenge. let registerChallenge = try await api.passkeyRegister( credentialId: attestation.credentialID.base64URLEncoded, clientDataJson: attestation.rawClientDataJSON.base64URLEncoded, attestationObject: attestation.rawAttestationObject!.base64URLEncoded, nickname: "This device", ) // 4. Client stamps the registration payload with an existing session key. let gridWalletSignature = try buildGridWalletSignature( signingKey: existingSessionSigningKey, payloadToSign: registerChallenge.payloadToSign, ) let registered = try await api.passkeyRegisterComplete( requestId: registerChallenge.requestId, gridWalletSignature: gridWalletSignature, ) // 5. Generate client key pair, ask Grid for an authentication challenge // bound to that public key via POST /auth/credentials/{id}/challenge. let keys = generateClientKeyPair() let challengeResp = try await api.passkeyChallenge( credentialId: registered.credentialId, clientPublicKeyHex: keys.publicKeyHex, ) // 6. Run WebAuthn assertion against Grid-issued challenge. The returned // value is a lowercase hex string; UTF-8 encode it as challenge bytes. let assertRequest = provider.createCredentialAssertionRequest( challenge: Data(challengeResp.challenge.utf8), ) assertRequest.allowedCredentials = [ ASAuthorizationPlatformPublicKeyCredentialDescriptor( credentialID: Data(base64URLEncoded: registered.credentialId)!, ), ] let assertion = try await withCheckedThrowingContinuation { (cont: CheckedContinuation< ASAuthorizationPlatformPublicKeyCredentialAssertion, Error >) in let controller = ASAuthorizationController(authorizationRequests: [assertRequest]) controller.delegate = AssertionDelegate(cont: cont) controller.performRequests() } // 7. Backend -> POST /verify with Request-Id. Returns the AuthSession. return try await api.passkeyVerify( credentialId: registered.credentialId, requestId: challengeResp.requestId, clientDataJson: assertion.rawClientDataJSON.base64URLEncoded, authenticatorData: assertion.rawAuthenticatorData.base64URLEncoded, signature: assertion.signature.base64URLEncoded, userHandle: assertion.userID?.base64URLEncoded, ) } } ``` #### WebAuthn → Grid parameter map These are the fields you need to pass through on each hop. **Registration (`/auth/credentials`):** | Browser (`credential`) | Your backend payload | Grid request body | | - | - | - | | `credential.rawId` | `credentialId` | `attestation.credentialId` | | `response.clientDataJSON` | `clientDataJson` | `attestation.clientDataJson` | | `response.attestationObject` | `attestationObject` | `attestation.attestationObject` | | `response.getTransports()` | `transports` | `attestation.transports` | | *(backend session state)* | `challenge` | `challenge` *(top-level, not under `attestation`)* | | *(backend-chosen)* | `nickname` | `nickname` | | *(Grid account id)* | — | `accountId` | **Authentication challenge (`/auth/credentials/{id}/challenge`):** | Source | Your backend payload | Grid request body | | - | - | - | | *(client-generated)* | `clientPublicKey` | `clientPublicKey` | **Assertion (`/auth/credentials/{id}/verify`):** | Browser (`credential`) | Your backend payload | Grid request body | | - | - | - | | `credential.rawId` | `credentialId` | `assertion.credentialId` | | `response.clientDataJSON` | `clientDataJson` | `assertion.clientDataJson` | | `response.authenticatorData` | `authenticatorData` | `assertion.authenticatorData` | | `response.signature` | `signature` | `assertion.signature` | | `response.userHandle` | `userHandle` | `assertion.userHandle` *(optional)* | | *(from `/challenge` response)* | `requestId` | `Request-Id` header | > [!NOTE] > > The WebAuthn spec names the field `clientDataJSON`. Grid spells it `clientDataJson` (lowercase `json`) for consistency with the rest of the API. The **bytes are identical** — only the field name changes. ### Passkey reauthentication When a session expires the client re-verifies without recreating the credential. Reauthentication uses the same `/challenge` → `/verify` shape as the first authentication: generate a fresh client key pair, call `POST /auth/credentials/{id}/challenge` with the new `clientPublicKey`, UTF-8 encode the returned challenge string for `navigator.credentials.get()`, then call `/verify` with the assertion and the matching `Request-Id` header. ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid participant A as Authenticator C->>IB: session expired, re-authenticate C->>C: generateClientKeyPair() C->>IB: POST /my-backend/passkey/challenge (clientPublicKey) IB->>G: POST /auth/credentials/{id}/challenge
          { clientPublicKey } G-->>IB: 200 PasskeyAuthChallenge { challenge, requestId, expiresAt } IB-->>C: { challenge, requestId } C->>A: navigator.credentials.get({ challenge }) A-->>C: assertion C->>IB: POST /my-backend/passkey/reauth (assertion, requestId) IB->>G: POST /auth/credentials/{id}/verify
          Request-Id: requestId
          { type: PASSKEY, assertion } G-->>IB: 200 AuthSession { id, expiresAt } IB-->>C: { sessionId, expiresAt } ``` ## OAuth (OIDC) Use an OAuth credential when your platform already authenticates the user with an OpenID Connect identity provider (Google, Apple, your own IdP) and you want Grid to trust that same identity. Adding OAuth to an initialized Global Account requires an active session from an existing credential and uses the same signed-retry pattern as passkey registration. ### OAuth registration ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid participant OP as OIDC provider C->>OP: Sign-in flow OP-->>C: id_token (OIDC) C->>IB: POST /my-backend/oauth/register { oidcToken } IB->>G: POST /auth/credentials { type: OAUTH, oidcToken, accountId } G-->>IB: 202 { payloadToSign, requestId, expiresAt } IB-->>C: { payloadToSign, requestId } C->>C: stamp(payloadToSign, existingSessionPrivateKey) C->>IB: { stamp, requestId } IB->>G: Same POST
          Grid-Wallet-Signature: stamp
          Request-Id: requestId G->>OP: fetch .well-known/openid-configuration + jwks G-->>IB: 201 AuthMethod IB-->>C: { credentialId } C->>C: generateClientKeyPair() C->>OP: get fresh id_token (iat within 60s) OP-->>C: id_token C->>IB: POST /my-backend/oauth/verify { oidcToken, clientPublicKey } IB->>G: POST /auth/credentials/{id}/verify { type: OAUTH, oidcToken, clientPublicKey } G-->>IB: 200 AuthSession IB-->>C: { sessionId, expiresAt } ``` Grid validates the OIDC token signature against the issuer's JWKS on every call and requires `iat` to be no more than **60 seconds** older than the request. Use a fresh token for each `verify` call; cached tokens will fail. The token identity (`iss`, `aud`, and `sub`) must match the OAuth credential being verified. > [!NOTE] > > In sandbox, OAuth still uses JWT-shaped OIDC tokens. The sandbox skips real IdP signature verification, but it validates the same identity and freshness claims. For `verify`, include `nonce` equal to the lowercase hex SHA-256 digest of the `clientPublicKey` string exactly as sent: the compressed key in lowercase hex. See [Sandbox testing](/global-accounts/platform-tools/sandbox-testing#oauth-oidc-token). ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "OAUTH", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9..." }' ``` **Response on the signed retry:** `201 AuthMethod` with `nickname` populated from the OIDC token's `email` claim. ### OAuth verify / reauthentication `POST /auth/credentials/{id}/verify` is also the reauthentication path — call it with a fresh OIDC token whenever the session expires. ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "OAUTH", "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9...", "clientPublicKey": "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" }' ``` ## Email OTP The lowest-friction credential type — works on any device with email access and requires no biometric hardware, identity provider, or client-side setup beyond an input field for the code. Global Accounts are initialized with an `EMAIL_OTP` credential; call `POST /auth/credentials` for `EMAIL_OTP` only if the credential was removed and you need to add it back. ### Default Email OTP credential Grid creates the first `EMAIL_OTP` credential when the Global Account is provisioned. The credential uses the customer email on file for the internal account. To authenticate with it, send an OTP challenge, then verify using the secure encrypted OTP flow. The client never sends the plaintext OTP code. Instead, it HPKE-encrypts the code (together with a fresh public key) to an enclave bundle returned from the challenge. The server is a pass-through and never sees the plaintext. ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid participant E as Email C->>IB: POST /my-backend/otp/challenge { credentialId } IB->>G: POST /auth/credentials/{id}/challenge G->>E: deliver OTP email (to customer email on file) G-->>IB: 200 AuthMethod + otpEncryptionTargetBundle IB-->>C: { otpEncryptionTargetBundle } E-->>C: OTP code C->>C: generateClientKeyPair() (TEK) C->>C: HPKE-encrypt { otp_code, public_key } → encryptedOtpBundle C->>IB: POST /my-backend/otp/verify { encryptedOtpBundle } IB->>G: POST /auth/credentials/{id}/verify { type: EMAIL_OTP, encryptedOtpBundle } G-->>IB: 202 { payloadToSign, requestId } IB-->>C: { payloadToSign, requestId } C->>C: sign(payloadToSign, tekPrivateKey) C->>IB: POST /my-backend/otp/verify/complete { stamp, requestId } IB->>G: Same POST + Grid-Wallet-Signature + Request-Id G-->>IB: 200 AuthSession IB-->>C: { expiresAt } ``` ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/challenge" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (200):** ```json theme={null} { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000004", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "otpEncryptionTargetBundle": "{\"version\":\"v1.0.0\",\"data\":\"7b22...\",\"dataSignature\":\"3045...\",\"enclaveQuorumPublic\":\"04a1...\"}", "createdAt": "2026-04-19T12:00:00Z", "updatedAt": "2026-04-19T12:00:00Z" } ``` The client generates a fresh P-256 key pair (the TEK — Target Encryption Key), HPKE-encrypts `{otp_code, public_key}` under `otpEncryptionTargetBundle`, and submits the encrypted payload. `public_key` must be the compressed public key (`02` or `03` prefix). See [Encrypt the OTP code](client-keys#encrypt-the-otp-code-email_otp-only) for implementation details. Then verify with the encrypted OTP bundle: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "EMAIL_OTP", "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" }' ``` **Response (202):** ```json theme={null} { "type": "EMAIL_OTP", "payloadToSign": "{\"login\":{\"publicKey\":\"02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31\"},\"tokenId\":\"7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21\",\"type\":\"USAGE_TYPE_LOGIN\"}", "requestId": "Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21", "expiresAt": "2026-04-19T12:05:00Z" } ``` The client stamps the exact UTF-8 bytes of `payloadToSign`, unchanged, with the TEK private key (the same key whose public key was encrypted in the bundle), then retries the same request body with the stamp: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ -d '{ "type": "EMAIL_OTP", "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" }' ``` **Response (200):** ```json theme={null} { "id": "Session:019542f5-b3e7-1d02-0000-000000000003", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-19T12:00:01Z", "updatedAt": "2026-04-19T12:00:01Z", "expiresAt": "2026-04-19T12:15:01Z" } ``` The TEK public key becomes the session API key. The response carries no key material: the client already holds the session signing key (the TEK private key it generated). `verify` shares its terminal status code with the still-processing response: a `200` here is only a session if the body isn't `{ "status": "PROCESSING" }`. See [handling a still-processing response](#handling-a-still-processing-response) — if you get `PROCESSING`, re-send the identical signed retry (same `encryptedOtpBundle`, `Grid-Wallet-Signature`, and `Request-Id`) until you get the session shown above. > [!NOTE] > > **In sandbox, the OTP code is always `000000`** — encrypt that value in the bundle. The sandbox runs real HPKE end-to-end; the only shortcut is skipping email delivery. See [Client keys](client-keys#encrypt-the-otp-code-email_otp-only) for the encryption flow. ### Resending an OTP If the code expires or the email didn't arrive, re-issue the challenge with `POST /auth/credentials/{id}/challenge`. This sends a fresh OTP email and leaves the `AuthMethod` otherwise untouched. `challenge` can also return a [still-processing `200`](#handling-a-still-processing-response) while the resend settles at the wallet provider — re-request the challenge until you get the `AuthMethod` back rather than a fresh OTP being sent twice. ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/challenge" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [!WARNING] > > Challenge re-issues are rate-limited. On `429`, back off for the duration returned in the `Retry-After` header before retrying. ### Email OTP reauthentication Same pattern as the first activation: call `/challenge` to send a new OTP and receive a fresh `otpEncryptionTargetBundle`, generate a new TEK key pair, build the `encryptedOtpBundle`, and complete the two-step verify flow. ### Changing the sign-in email or phone number The `EMAIL_OTP` address is the customer email on file, and the `SMS_OTP` number is the customer phone number. The customer must prove they control a new address or number before it replaces the one they sign in with. Changing it takes four calls, and the same steps apply to `phoneNumber` with `SMS_OTP`. **1. Register the new address** Call `POST /auth/credentials` with the new address. Grid sends a code to it and returns the replacement credential with an `otpEncryptionTargetBundle`. The current credential keeps working until the change lands. ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "EMAIL_OTP", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "email": "jane.new@example.com" }' ``` **2. Verify the code** Encrypt the code the customer received to that `otpEncryptionTargetBundle`, as for a login, and send it to `POST /auth/credentials/{id}/verify` with the replacement's id. Grid answers `204` with no session. A replacement that is not verified within 24 hours expires. **3. Update the customer** Call `PATCH /customers/{customerId}` with only `customerType` and the new address. Grid answers `202` with `payloadToSign`, `requestId`, and `expiresAt`. Make this call soon after verifying. If the verification would lapse before the challenge does, Grid returns `400 AUTH_CREDENTIAL_VERIFICATION_EXPIRED`, and the customer must verify again. ```bash theme={null} curl -X PATCH "$GRID_BASE_URL/customers/Customer:019542f5-b3e7-1d02-0000-000000000001" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerType": "INDIVIDUAL", "email": "jane.new@example.com" }' ``` **4. Signed retry** The client stamps `payloadToSign` with an active session signing key on the same account. Retry the identical request with `Grid-Wallet-Signature` and `Request-Id`. Grid answers `200` with the updated customer, and the new address becomes the account's `EMAIL_OTP` credential in place of the old one. A `200` with `{ "status": "PROCESSING" }` means the change is still landing: re-send the identical signed request until it returns the customer. A `PATCH` that changes the sign-in address without a verified replacement returns `400 AUTH_CREDENTIAL_VERIFICATION_REQUIRED`. The change must be sent on its own, so a request that also changes other customer fields returns `400 INVALID_INPUT`. Customers without an `EMAIL_OTP` or `SMS_OTP` credential of that type change `email` or `phoneNumber` with a plain update. ## Managing credentials Every Global Account starts with a single `EMAIL_OTP` credential — the one used in the [quickstart](overview#quickstart). In production, encourage customers to register a backup credential, such as a passkey or OAuth credential, so the account is recoverable if their primary device is lost. Adding, revoking, and rotating credentials after the first all go through the same **two-step signed-retry** pattern. ### List credentials ```bash theme={null} curl -X GET "$GRID_BASE_URL/auth/credentials?accountId=InternalAccount:019542f5-b3e7-1d02-0000-000000000002" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (200):** ```json theme={null} { "data": [ { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000001", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "PASSKEY", "credentialId": "KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Pxg6jo_o0hYiew", "nickname": "iPhone Face-ID", "createdAt": "2026-04-08T15:30:01Z", "updatedAt": "2026-04-08T15:30:01Z" }, { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000004", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-09T10:15:00Z", "updatedAt": "2026-04-09T10:15:00Z" } ] } ``` The response is not paginated — each account holds a small, bounded number of credentials. ### The signed-retry pattern Adding an additional credential, revoking a credential, refreshing or revoking a session, exporting a wallet, updating wallet privacy, and changing a sign-in email or phone number all share the same shape: ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid IB->>G: Request (no headers) G-->>IB: 202 { payloadToSign, requestId, expiresAt } IB-->>C: { payloadToSign, requestId } C->>C: stamp(payloadToSign, sessionPrivateKey) C->>IB: { stamp } IB->>G: Same request
          Grid-Wallet-Signature: stamp
          Request-Id: requestId alt still settling at the wallet provider G-->>IB: 200 { status: "PROCESSING" } IB->>G: Re-send the identical signed request end G-->>IB: 2xx (terminal success) IB-->>C: done ``` Key rules: * Always stamp the `payloadToSign` **byte-for-byte as Grid returned it**. Do not re-parse, re-serialize, or modify whitespace. * Sign with the **session private key** held on the client — never ship it back to your backend. * The retry must reach Grid before `expiresAt` (typically 5 minutes from issue). * The `requestId` is returned as `Request:` and is single-use; reusing one yields `401`. ### Handling a still-processing response The signed retry can come back `200` with a `WalletOperationProcessing` body instead of the terminal success shown above: ```json theme={null} { "status": "PROCESSING", "message": "This login is still being processed. Retry the same request in a moment." } ``` This means Grid's wallet provider accepted the operation but hasn't settled it yet — the request is not lost, and nothing failed. **Design for this as the normal path, not a rare edge case**: handle it the same way you handle the terminal success and error responses below, not as a bolted-on afterthought. Every signed-retry endpoint can return it on the retry step: | Endpoint | Action | | - | - | | `POST /auth/credentials` | Add a credential | | `POST /auth/credentials/{id}/challenge` | Re-send an OTP / re-issue a challenge | | `POST /auth/credentials/{id}/verify` | Log in / verify a credential | | `POST /auth/sessions/{id}/refresh` | Refresh a session | | `DELETE /auth/credentials/{id}` | Revoke a credential | | `DELETE /auth/sessions/{id}` | Revoke a session | | `POST /internal-accounts/{id}/export` | Export wallet credentials | | `PATCH /customers/{customerId}` | Change a sign-in email or phone number | To handle it: 1. **Re-send the exact same signed request** — identical body, `Grid-Wallet-Signature`, and `Request-Id`. Don't regenerate the stamp or start a fresh request; Grid correlates the retry with the same underlying operation and returns its real outcome once the provider settles, rather than starting a second one. 2. **Show a pending state in your UI** while you retry — "removing…" for a credential revoke, "signing in…" for a login — instead of treating `PROCESSING` as an error or leaving the caller blocked with no feedback. 3. **Keep retrying with backoff** until you get a terminal response — the success code above, or an error. Grid also reconciles the operation to its terminal state on its own, so even a client that stops retrying and checks back later (for example via `GET /auth/credentials` or `GET /auth/sessions`) will see the settled result. **Wallet export is the exception**: Grid never stores the encrypted credentials, so re-sending the signed retry is the only way to receive them. Grid's own reconciliation settles the operation but has nothing to hand back, and the export challenge expires like any other (typically 5 minutes) — if it lapses, start a fresh export. > [!NOTE] > > `verify`, `challenge`, `export`, and the customer update already use `200` for their terminal success body (`AuthSession`, the challenge response, the encrypted credentials, or the customer). For those four, `200` alone doesn't tell you which case you're in — check the response body's `status` field for `"PROCESSING"` to distinguish it from a settled success. The other four endpoints (`add credential`, `session refresh`, `revoke credential`, `revoke session`) use `201` or `204` on success, so a `200` by itself already means still-processing. ### Add an additional credential Requires an active session on an *existing* credential on the same account. The first call uses the normal credential-create body; Grid detects the pre-existing credential and responds `202` instead of `201`. `OAUTH` and `PASSKEY` are the typical additional credential types. `EMAIL_OTP` can be added back only after the existing email OTP credential has been removed, because each account supports one. **1. First call — receive the challenge** ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "OAUTH", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9..." }' ``` **Response (202):** ```json theme={null} { "type": "OAUTH", "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"oauthProviders\":[{\"oidcToken\":\"eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9...\",\"providerName\":\"Google\"}],\"userId\":\"user_2m9F...\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_CREATE_OAUTH_PROVIDERS\"}", "requestId": "Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21", "expiresAt": "2026-04-08T15:35:00Z" } ``` **2. Client stamps the payload** Send `payloadToSign` to the client. The client builds a Grid wallet signature with the session signing key from the existing credential's active session — see [signing payloads](client-keys#2-sign-a-payloadtosign). **3. Signed retry — credential is created** Re-run the same request with the stamp and request id in headers: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ -d '{ "type": "OAUTH", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9..." }' ``` **Response (201):** a plain `AuthMethod`. If the underlying wallet-provider activity is still settling, this returns `200 { status: "PROCESSING" }` instead — see [handling a still-processing response](#handling-a-still-processing-response); re-send the identical signed request until you get the `201`. **4. Activate the new credential** Activate the new credential the same way you would activate the first credential of that type — `OAUTH` goes straight to `POST /auth/credentials/{id}/verify` with a fresh `clientPublicKey`; `EMAIL_OTP` uses the `otpEncryptionTargetBundle` from the signed-retry registration response when present, or first calls `POST /auth/credentials/{id}/challenge` if the bundle is absent; `PASSKEY` first calls `POST /auth/credentials/{id}/challenge` with the `clientPublicKey` to get a Grid-issued WebAuthn challenge, then `POST /auth/credentials/{id}/verify` with the assertion and the `Request-Id` header. > [!NOTE] > > The account holder may have one `EMAIL_OTP` credential per internal account; an administrator's credentials, added with `POST /auth/admin-credentials`, are not counted. An administrator's session cannot add credentials with `POST /auth/credentials` (`403 FORBIDDEN`); administrators add their own with `POST /auth/admin-credentials`. Multiple distinct `PASSKEY` credentials are allowed; registering the same WebAuthn credentialId twice returns `400 PASSKEY_CREDENTIAL_ALREADY_EXISTS`. ### Revoke a credential A credential is revoked by signing with a session from **a different credential on the same account**. This prevents a compromised credential from revoking itself to lock the legitimate owner out. An account must keep at least one credential — if only one exists, the revoke call returns `400`. **1. First call — receive the challenge** ```bash theme={null} curl -X DELETE "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (202):** ```json theme={null} { "type": "PASSKEY", "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"authenticatorIds\":[\"authenticator_2m9F...\"],\"userId\":\"user_2m9F...\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_DELETE_AUTHENTICATORS\"}", "requestId": "Request:9f7a2c10-5e88-4fb1-bd0e-1c3a8e7b2d45", "expiresAt": "2026-04-08T15:35:00Z" } ``` **2. Client stamps with a different credential's session** The client stamps `payloadToSign` with the session signing key of an active session on any *other* credential (not the one being revoked). **3. Signed retry — credential is revoked** ```bash theme={null} curl -X DELETE "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:9f7a2c10-5e88-4fb1-bd0e-1c3a8e7b2d45" ``` **Response:** `204 No Content`. All active sessions issued by the revoked credential are also revoked. If the revocation is still settling at the wallet provider, this returns `200 { status: "PROCESSING" }` instead — [re-send the identical `DELETE`](#handling-a-still-processing-response) until you get the `204`, and show the credential as "removing…" in your UI in the meantime rather than assuming success or failure. # Client keys & signing Source: https://agents-docs.lightspark.com/global-p2p/global-accounts/client-keys Generate the P-256 client key pair, hold the session signing key, and sign account actions on Web, iOS, and Android Every signed Global Account action uses two key pairs: | Key pair | Where it lives | What it does | | - | - | - | | **Client key pair** (P-256) | On the customer's device, generated fresh per session-issuing or export request | For authentication and session refresh, its private key becomes the session signing key. For wallet export, its public key is the recipient key Grid encrypts the wallet credentials to. Ephemeral — one pair per authentication, session refresh, or wallet export. | | **Session signing key** (P-256) | Held on the customer's device | Signs every account action for the lifetime of the session (default 15 minutes). It is the private key of the client key pair sent on the request that issued the session. | This page covers generating the client key pair, sending the public key to your backend, holding the session signing key, and signing payloads. Everything here runs **on the client**; your integrator backend only relays opaque byte strings. ## 1. Generate a client key pair The key pair you generate here *is* your session key: you keep the private key on the device and use it directly to sign account actions. Grid never transmits a session key, so session-issuing responses carry no key material. Generate it as a P-256 **signing** key (ECDSA). Generate a fresh key pair for every authentication and session refresh. Where the public key goes depends on the flow: * `PASSKEY`: send it as `clientPublicKey` on `POST /auth/credentials/{id}/challenge`. * `OAUTH`: send it as `clientPublicKey` on `POST /auth/credentials/{id}/verify`. * `EMAIL_OTP`: put it inside `encryptedOtpBundle` on `POST /auth/credentials/{id}/verify`. See [Encrypt the OTP code](#encrypt-the-otp-code-email_otp-only). * Session refresh: send it as `clientPublicKey` on both `/auth/sessions/{id}/refresh` calls. Wallet export uses a different kind of key pair. See [Wallet export key pair](#wallet-export-key-pair). Keep the private key in device-local secure storage (iOS Keychain, Android Keystore, or in the browser, `IndexedDB` encrypted under a non-extractable Web Crypto key). Send the public key through your integrator backend as a **compressed** SEC1 point in **lowercase** hex: 66 characters, a `02` or `03` prefix followed by the 32-byte X coordinate. Grid rejects an uppercase or uncompressed key with `400 INVALID_INPUT`. > [!TIP] > > For local development, you can generate a P-256 key pair from the command line: > > ```bash theme={null} > # Private key (PKCS#8 PEM) > openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out private.pem > > # Compressed public key as lowercase hex > openssl ec -in private.pem -pubout -conv_form compressed -outform DER 2>/dev/null \ > | tail -c 33 | xxd -p -c 33 > ``` ```typescript Web (TypeScript) theme={null} // npm i @noble/curves import { p256 } from "@noble/curves/nist.js"; function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } // Generate a P-256 signing key pair. The 32-byte private key becomes the // session signing key, so store it encrypted at rest on the device. function generateClientKeyPair(): { privateKey: Uint8Array; publicKeyHex: string; } { const privateKey = p256.utils.randomSecretKey(); // Compressed SEC1 form: 33 bytes, 0x02 or 0x03 followed by X. const publicKeyHex = bytesToHex(p256.getPublicKey(privateKey, true)); return { privateKey, publicKeyHex }; } ``` ```kotlin Android (Kotlin) theme={null} import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import java.math.BigInteger import java.security.KeyPairGenerator import java.security.interfaces.ECPublicKey import java.security.spec.ECGenParameterSpec data class ClientKeyPair(val alias: String, val publicKeyHex: String) private fun BigInteger.toFixed32(): ByteArray { val bytes = toByteArray() return ByteArray(32).also { out -> val copyFrom = maxOf(0, bytes.size - 32) val copyLength = bytes.size - copyFrom System.arraycopy(bytes, copyFrom, out, 32 - copyLength, copyLength) } } fun generateClientKeyPair(alias: String): ClientKeyPair { val generator = KeyPairGenerator.getInstance( KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore", ) generator.initialize( KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN) .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) .setDigests(KeyProperties.DIGEST_SHA256) .build(), ) val point = (generator.generateKeyPair().public as ECPublicKey).w // Compressed SEC1 form: 0x02 for an even Y, 0x03 for an odd Y, then X. val prefix = if (point.affineY.testBit(0)) 0x03.toByte() else 0x02.toByte() val compressed = byteArrayOf(prefix) + point.affineX.toFixed32() val hex = compressed.joinToString("") { "%02x".format(it) } return ClientKeyPair(alias = alias, publicKeyHex = hex) } ``` ```swift iOS (Swift) theme={null} import CryptoKit struct ClientKeyPair { let privateKey: P256.Signing.PrivateKey let publicKeyHex: String } func generateClientKeyPair() -> ClientKeyPair { let privateKey = P256.Signing.PrivateKey() // compressedRepresentation is the 33-byte SEC1 form (0x02 or 0x03, then X). let raw = privateKey.publicKey.compressedRepresentation let hex = raw.map { String(format: "%02x", $0) }.joined() return ClientKeyPair(privateKey: privateKey, publicKeyHex: hex) } ``` > [!NOTE] > > The private key **must not leave the device**. Your integrator backend only ever sees `publicKeyHex`. ### Wallet export key pair Wallet export encrypts the wallet credentials to the key you send, so it needs a P-256 **key agreement** (ECDH) key pair rather than a signing key. Generate a fresh one for each export and discard the private key after decrypting. Send its public key as `clientPublicKey` on both `/export` calls, as an **uncompressed** SEC1 point in **lowercase** hex: 130 characters, a `04` prefix followed by the 32-byte X and 32-byte Y coordinates. The samples below produce that form. See [Exporting a wallet](exporting-wallet) for the full flow. ```typescript Web (TypeScript) theme={null} function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } // Generate a non-extractable P-256 key pair in the browser. // The private key never leaves Web Crypto; only the public key is exported. async function generateExportKeyPair(): Promise<{ keyPair: CryptoKeyPair; publicKeyHex: string; }> { const keyPair = await crypto.subtle.generateKey( { name: "ECDH", namedCurve: "P-256" }, false, // private key non-extractable ["deriveBits"], ); const raw = new Uint8Array( await crypto.subtle.exportKey("raw", keyPair.publicKey), ); // exportKey("raw") returns the 65-byte uncompressed form (0x04 || X || Y). const publicKeyHex = bytesToHex(raw); return { keyPair, publicKeyHex }; } ``` ```kotlin Android (Kotlin) theme={null} import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import java.math.BigInteger import java.security.KeyPairGenerator import java.security.interfaces.ECPublicKey import java.security.spec.ECGenParameterSpec data class ExportKeyPair(val alias: String, val publicKeyHex: String) // toFixed32() is the helper from the client key pair sample above. fun generateExportKeyPair(alias: String): ExportKeyPair { val generator = KeyPairGenerator.getInstance( KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore", ) generator.initialize( KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_AGREE_KEY) .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) .build(), ) val point = (generator.generateKeyPair().public as ECPublicKey).w val uncompressed = byteArrayOf(0x04) + point.affineX.toFixed32() + point.affineY.toFixed32() val hex = uncompressed.joinToString("") { "%02x".format(it) } return ExportKeyPair(alias = alias, publicKeyHex = hex) } ``` ```swift iOS (Swift) theme={null} import CryptoKit struct ExportKeyPair { let privateKey: P256.KeyAgreement.PrivateKey let publicKeyHex: String } func generateExportKeyPair() -> ExportKeyPair { let privateKey = P256.KeyAgreement.PrivateKey() // x963Representation returns uncompressed SEC1 (65 bytes starting with 0x04). let raw = privateKey.publicKey.x963Representation let hex = raw.map { String(format: "%02x", $0) }.joined() return ExportKeyPair(privateKey: privateKey, publicKeyHex: hex) } ``` ## Encrypt the OTP code (`EMAIL_OTP` only) `EMAIL_OTP` credentials never send the OTP code in plaintext. Instead, the client HPKE-encrypts the code, together with its public key, to an enclave key, so the code is unreadable in transit and Grid is only a pass-through. Grid returns an `otpEncryptionTargetBundle` whenever it initiates or reissues an OTP challenge, including `POST /auth/credentials/{id}/challenge` and add-EMAIL\_OTP signed-retry responses. First-time EMAIL\_OTP wallet bootstrap registration can omit it; if the registration response has no bundle, call `POST /auth/credentials/{id}/challenge` for that credential before verifying. The bundle is a signed enclave bundle whose `data` field is hex-encoded JSON carrying the enclave's HPKE target key as `targetPublic`. Pull out `targetPublic`, HPKE-encrypt `{ otp_code, public_key }` to it, and submit the library's `{ encappedPublic, ciphertext }` output as `encryptedOtpBundle` on `POST /auth/credentials/{id}/verify`. `public_key` must be the **compressed** public key in lowercase hex: 66 characters starting with `02` or `03`. The step 1 samples produce this form. The OTP login challenge is bound to this exact key, so the same compressed hex must be the `publicKey` in the `Grid-Wallet-Signature` stamp on the signed retry (see [step 2](#2-sign-a-payloadtosign)); a different value there fails the login. In production, an uncompressed key makes the enclave reject the OTP code, even when the code is correct. The first verify call then returns: ```json theme={null} {"code": "INVALID_INPUT", "reason": "Invalid wallet request."} ``` Grid returns this same response for other rejected requests too, including a mistyped OTP code. If you get it with a code you know is correct, check the key format first. Use an HPKE library so you don't hand-roll the suite, `info`, or AAD. The helper below assumes your crypto layer returns the JSON string Grid expects for `encryptedOtpBundle`. ```typescript Web (TypeScript) theme={null} // npm i @noble/hashes import { hexToBytes } from "@noble/hashes/utils.js"; declare function hpkeEncryptToGridBundle(args: { plainTextBuf: Uint8Array; targetKeyBuf: Uint8Array; }): string; // otpEncryptionTargetBundle: from the registration response when present, or /challenge // clientPublicKeyHex: the compressed public key from step 1 // otp: the code the user typed ("000000" in sandbox) function buildEncryptedOtpBundle( otpEncryptionTargetBundle: string, clientPublicKeyHex: string, otp: string, ): string { // Pull the enclave's target key out of the signed bundle. const { data } = JSON.parse(otpEncryptionTargetBundle) as { data: string }; const { targetPublic } = JSON.parse( new TextDecoder().decode(hexToBytes(data)), ) as { targetPublic: string }; // The enclave expects snake_case keys. const plainTextBuf = new TextEncoder().encode( JSON.stringify({ otp_code: otp, public_key: clientPublicKeyHex }), ); return hpkeEncryptToGridBundle({ plainTextBuf, targetKeyBuf: hexToBytes(targetPublic), }); } ``` > [!NOTE] > > A production client should also verify the bundle's `dataSignature` against its `enclaveQuorumPublic` before trusting `targetPublic`. The private key of the pair you generated in step 1 stays on the device. It becomes the session signing key once verification completes (see [Sign a `payloadToSign`](#2-sign-a-payloadtosign)). ## 2. Sign a `payloadToSign` Grid returns `payloadToSign` strings from several endpoints: * `POST /quotes` (when the source is a Global Account) — the quote's `paymentInstructions[].accountOrWalletInfo.payloadToSign`. * `POST /auth/credentials/{id}/verify` (`EMAIL_OTP` or `SMS_OTP`) — 202 response body; use the TEK private key generated for this login. * `POST /auth/credentials` (adding an additional credential) — 202 response body. * `DELETE /auth/credentials/{id}`, `DELETE /auth/sessions/{id}`, `POST /auth/sessions/{id}/refresh`, `POST /internal-accounts/{id}/export`, `PATCH /internal-accounts/{id}`, `PATCH /customers/{id}` for sign-in email or phone number changes — all 202 response bodies. Stamp the exact UTF-8 bytes of the `payloadToSign` string **as returned** (do not re-parse, re-serialize, or trim whitespace). For OTP verification, use the matching TEK private key; for other signed wallet actions, use an active session signing key. Build a Grid wallet signature: take its compressed P-256 public key in lowercase hex, sign the payload with the private scalar, then base64url-encode the signature envelope: ```json theme={null} { "publicKey": "", "scheme": "SIGNATURE_SCHEME_TK_API_P256", "signature": "" } ``` Pass that full stamp as the `Grid-Wallet-Signature` header on the retry (and, for endpoints that use it, echo the 202 `requestId` as `Request-Id`). > [!NOTE] > > Sign with real stamps in sandbox too. Sandbox verifies them exactly as production does. OTP verification requires a stamp over the returned `payloadToSign` with the TEK private key. ```typescript Web (TypeScript) theme={null} declare function deriveCompressedP256PublicKey(privateKeyHex: string): string; declare function signP256Sha256Der(args: { content: string; privateKeyHex: string; }): Promise; const GRID_STAMP_SCHEME = "SIGNATURE_SCHEME_TK_API_P256"; function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } function base64url(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) { binary += String.fromCharCode(byte); } return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } async function buildGridWalletSignature( sessionPrivateKeyBytes: Uint8Array, // 32 bytes, the step 1 private key payloadToSign: string, ): Promise { const apiPrivateKey = bytesToHex(sessionPrivateKeyBytes); const apiPublicKey = deriveCompressedP256PublicKey(apiPrivateKey); const signature = await signP256Sha256Der({ content: payloadToSign, privateKeyHex: apiPrivateKey, }); const stamp = JSON.stringify({ publicKey: apiPublicKey, scheme: GRID_STAMP_SCHEME, signature, }); return base64url(new TextEncoder().encode(stamp)); } ``` ```kotlin Android (Kotlin) theme={null} import android.util.Base64 import java.security.KeyStore import java.security.PrivateKey import java.security.Signature private const val GRID_STAMP_SCHEME = "SIGNATURE_SCHEME_TK_API_P256" private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } private fun base64url(value: String): String = Base64.encodeToString( value.toByteArray(Charsets.UTF_8), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP, ) fun buildGridWalletSignature( sessionKeys: ClientKeyPair, // from generateClientKeyPair in step 1 payloadToSign: String, ): String { val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } val privateKey = keyStore.getKey(sessionKeys.alias, null) as PrivateKey val signer = Signature.getInstance("SHA256withECDSA").apply { initSign(privateKey) update(payloadToSign.toByteArray(Charsets.UTF_8)) } val derSignatureHex = signer.sign().hex() // JCE returns DER-encoded ECDSA. val stampJson = """{"publicKey":"${sessionKeys.publicKeyHex}","scheme":"$GRID_STAMP_SCHEME","signature":"$derSignatureHex"}""" return base64url(stampJson) } ``` ```swift iOS (Swift) theme={null} import CryptoKit import Foundation func buildGridWalletSignature( signingKey: P256.Signing.PrivateKey, // from generateClientKeyPair in step 1 payloadToSign: String, ) throws -> String { let payload = Data(payloadToSign.utf8) let signature = try signingKey.signature(for: payload) let signatureHex = signature.derRepresentation.map { String(format: "%02x", $0) }.joined() let publicKeyHex = signingKey.publicKey.compressedRepresentation .map { String(format: "%02x", $0) } .joined() let stampJson = #"{"publicKey":"\#(publicKeyHex)","scheme":"SIGNATURE_SCHEME_TK_API_P256","signature":"\#(signatureHex)"}"# return Data(stampJson.utf8) .base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } ``` Your backend adds the stamp to the retry request: ```bash theme={null} curl -X POST "https://api.lightspark.com/grid/2025-10-13/quotes/Quote:019542f5-b3e7-1d02-0000-000000000006/execute" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" ``` ## Session lifetime Sessions are valid for 15 minutes by default. The `AuthSession.expiresAt` field tells you exactly when the session signing key stops being accepted. After expiry, the client must re-verify the credential (see [Authentication](authentication)) to obtain a fresh session. > [!WARNING] > > If the device is lost or compromised, the user should add a second credential from a trusted device and revoke the compromised one — see [Managing credentials](authentication#managing-credentials). To end the current browser or app session without touching credentials, see [Sessions](managing-sessions). # Exporting a wallet Source: https://agents-docs.lightspark.com/global-p2p/global-accounts/exporting-wallet Let a customer take the seed of their Global Account off Grid Exporting a wallet returns the wallet's mnemonic seed, encrypted to the client's public key. The customer decrypts it on their device and can then import the wallet into any compatible self-custody client. Grid never sees the plaintext seed leaving the system. Export uses the same [signed-retry pattern](authentication#the-signed-retry-pattern) as credential and session revocation — the initial `POST` returns a `payloadToSign`, and the signed retry returns the encrypted seed. Generate a fresh P-256 key agreement key pair specifically for the export (see [Wallet export key pair](client-keys#wallet-export-key-pair)). Send its public key as `clientPublicKey` on both export requests, as an uncompressed key in lowercase hex (130 characters, `04` prefix), then decrypt `encryptedWalletCredentials` with the matching private key after the signed retry succeeds. ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid IB->>G: POST /internal-accounts/{id}/export { clientPublicKey } G-->>IB: 202 { payloadToSign, requestId, expiresAt } IB-->>C: { payloadToSign, requestId } C->>C: stamp(payloadToSign, sessionPrivateKey) C->>IB: { stamp } IB->>G: POST /internal-accounts/{id}/export { same clientPublicKey }
          Grid-Wallet-Signature
          Request-Id alt still settling at the wallet provider G-->>IB: 200 { status: "PROCESSING" } IB->>G: Re-send the identical signed request end G-->>IB: 200 { id, encryptedWalletCredentials } IB-->>C: { encryptedWalletCredentials } C->>C: decrypt with client private key
          → mnemonic ``` **1. First call — receive the challenge** ```bash theme={null} curl -X POST "$GRID_BASE_URL/internal-accounts/InternalAccount:019542f5-b3e7-1d02-0000-000000000002/export" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "clientPublicKey": "04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2" }' ``` **Response (202):** ```json theme={null} { "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"targetPublicKey\":\"04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2\",\"walletId\":\"wallet_2m9F...\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_EXPORT_WALLET\"}", "requestId": "Request:c3f8a614-47e2-4a19-9f5d-2b0a91d47e08", "expiresAt": "2026-04-19T12:10:00Z" } ``` **2. Client stamps the payload** Build a Grid wallet signature over `payloadToSign` with an active session signing key on the account. Keep the export private key on the client; Grid will use the matching `clientPublicKey` from step 1 to seal the wallet credentials. **3. Signed retry — receive the encrypted seed** ```bash theme={null} curl -X POST "$GRID_BASE_URL/internal-accounts/InternalAccount:019542f5-b3e7-1d02-0000-000000000002/export" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:c3f8a614-47e2-4a19-9f5d-2b0a91d47e08" \ -d '{ "clientPublicKey": "04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2" }' ``` **Response (200):** ```json theme={null} { "id": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "encryptedWalletCredentials": "{\"version\":\"v1.0.0\",\"data\":\"7b22656e6361707065645075626c6963223a2230346634356632612e2e2e222c2263697068657274657874223a22316661313032333339302e2e2e222c226f7267616e697a6174696f6e4964223a226f72675f326d39462e2e2e227d\",\"dataSignature\":\"3045022100...\",\"enclaveQuorumPublic\":\"04a1b2c3...\"}" } ``` Export shares its terminal status code with the still-processing response: a `200` here is only the encrypted seed if the body isn't `{ "status": "PROCESSING" }`. See [handling a still-processing response](authentication#handling-a-still-processing-response) — if you get `PROCESSING`, re-send the identical signed retry (same `clientPublicKey`, `Grid-Wallet-Signature`, and `Request-Id`) until you get the credentials. Because Grid never stores them, the re-send is the only way to receive them; subscribe to `wallet_operation.completed` to know when it will succeed. The `Request-Id` challenge is consumed only by the attempt that actually returns credentials, so a `PROCESSING` response leaves it usable. **4. Decrypt on the client** `encryptedWalletCredentials` is a signed wallet export envelope. Parse the envelope, verify `dataSignature` against `enclaveQuorumPublic`, decode the hex `data` JSON to get `encappedPublic` and `ciphertext`, then decrypt with the export private key that matches the `clientPublicKey` you sent on both export requests. The plaintext is a BIP-39 mnemonic (the wallet's master seed). > [!WARNING] > > The exported mnemonic is the master key of the self-custody wallet. After decryption the customer is the only custodian — if the mnemonic is lost, the funds are lost. Surface appropriate warnings in your UI before running an export. # Sessions Source: https://agents-docs.lightspark.com/global-p2p/global-accounts/managing-sessions List and revoke active sessions on a Global Account Every call to `POST /auth/credentials/{id}/verify` creates a new **session** — an authenticated signing context with a 15-minute lifetime by default. Sessions accumulate: a customer signed in on a laptop and a phone has two active sessions, each with its own session signing key held on that device. Use the session endpoints to show active sign-ins, refresh an active session before it expires, and sign out of a specific device. ## List active sessions ```bash theme={null} curl -X GET "$GRID_BASE_URL/auth/sessions?accountId=InternalAccount:019542f5-b3e7-1d02-0000-000000000002" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (200):** ```json theme={null} { "data": [ { "id": "Session:019542f5-b3e7-1d02-0000-000000000003", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "PASSKEY", "nickname": "iPhone Face-ID", "createdAt": "2026-04-19T12:00:02Z", "updatedAt": "2026-04-19T12:00:02Z", "expiresAt": "2026-04-19T12:15:02Z" }, { "id": "Session:019542f5-b3e7-1d02-0000-000000000007", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-19T10:01:00Z", "updatedAt": "2026-04-19T10:01:00Z", "expiresAt": "2026-04-19T10:16:00Z" } ] } ``` The list endpoint returns all **active** sessions; expired sessions are not included. Session responses never carry the session signing key. The client generated it and already holds it. ## Refresh a session Session refresh issues a new session for a new session signing key, authorized by an existing active session. Generate a fresh client key pair and send its compressed public key in lowercase hex as `clientPublicKey` (see [Generate a client key pair](client-keys#1-generate-a-client-key-pair)). Use this when the customer is still present and the current session is close to expiration. If the session has already expired, reauthenticate with the original credential instead. Refresh uses the same [signed-retry pattern](authentication#the-signed-retry-pattern) as credential management, including the possibility of a [still-processing `200` response](authentication#handling-a-still-processing-response) on the signed retry — re-send the same request until you get the `201` below. **1. First call — receive the challenge** ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/sessions/Session:019542f5-b3e7-1d02-0000-000000000003/refresh" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "clientPublicKey": "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" }' ``` **Response (202):** ```json theme={null} { "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"expirationSeconds\":\"900\",\"publicKey\":\"02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_STAMP_LOGIN\"}", "requestId": "Request:8c1e7f55-7b9c-4383-86c7-0cbde77c7328", "expiresAt": "2026-04-19T12:10:00Z" } ``` **2. Client stamps the payload** Build a Grid wallet signature over `payloadToSign` with the current session signing key. **3. Signed retry — receive the refreshed session** ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/sessions/Session:019542f5-b3e7-1d02-0000-000000000003/refresh" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:8c1e7f55-7b9c-4383-86c7-0cbde77c7328" \ -d '{ "clientPublicKey": "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" }' ``` **Response (201):** the new `AuthSession`. The private key matching the `clientPublicKey` above is now the session signing key, so replace the old one on the client. ## Revoke a session Session revocation uses the same [signed-retry pattern](authentication#the-signed-retry-pattern) as credential management, including a possible [still-processing `200` response](authentication#handling-a-still-processing-response) on the signed retry. Unlike credential revocation, a session **can revoke itself** — this is how self-logout works: sign with the session key you are about to invalidate. **1. First call — receive the challenge** ```bash theme={null} curl -X DELETE "$GRID_BASE_URL/auth/sessions/Session:019542f5-b3e7-1d02-0000-000000000003" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (202):** ```json theme={null} { "type": "PASSKEY", "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"apiKeyIds\":[\"api_key_2m9F...\"],\"userId\":\"user_2m9F...\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_DELETE_API_KEYS\"}", "requestId": "Request:2b1e5a08-9c44-4e91-ae7f-6d0b3f8c1e22", "expiresAt": "2026-04-19T12:10:00Z" } ``` **2. Client stamps the payload** Build a Grid wallet signature over `payloadToSign` with any active session signing key on the same account — either the session being revoked (self-logout) or another session (admin-style sign-out of a different device). **3. Signed retry — session is revoked** ```bash theme={null} curl -X DELETE "$GRID_BASE_URL/auth/sessions/Session:019542f5-b3e7-1d02-0000-000000000003" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:2b1e5a08-9c44-4e91-ae7f-6d0b3f8c1e22" ``` **Response:** `204 No Content`. > [!TIP] > > Revoking a session only invalidates the session signing key, not the credential that issued it. The next call to `POST /auth/credentials/{id}/verify` on that credential still works and issues a brand new session. # Global Accounts Source: https://agents-docs.lightspark.com/global-p2p/global-accounts/overview Self-custody stablecoin accounts that plug into Grid payment flows A Global Account is what we consider a programmable, self-custodial account for holding and moving money. Under the hood, it is an embedded [Spark](https://spark.money) wallet that Lightspark provisions for your customer. It can hold different stablecoins, including those denominated in local currencies, as well as other digital assets such as Bitcoin. In the API, a Global Account is an internal account with `type: "EMBEDDED_WALLET"`. It uses the same customer, quote, transaction, and webhook primitives as other accounts. Receiving funds works the same way, too. To move money out, the customer must authorize the transfer on their device, which signs the payment with a session signing key. ## Why a Global Account? * **Self-custodial.** Neither Lightspark nor your platform can move funds unilaterally. Outbound transfers require authorization from the customer's device. * **Stablecoin-denominated.** Balances can be held in stablecoins like [Brale-issued USDB](https://brale.xyz/stablecoins/USDB). You can also issue your own stablecoin and [request enablement in Grid](/api-reference/stablecoins/request-grid-enablement-for-a-stablecoin). Use the `/quotes` API to convert from fiat or withdraw through supported payment rails. * **Native.** Reuse the customer, internal-account, quote, transaction, and webhook primitives you already use for payouts or P2P. Global Accounts add authentication and signing at the account level. ## Payment flow Global Accounts use the same `/quotes` and `/quotes/{id}/execute` pattern as other payments. Outbound transfers also require a client signature. * **Incoming funds.** Funding an account works like any other internal account. Create a quote with the Global Account as the `destination`, execute it, and Grid converts the source currency into USDB and credits the account. Incoming funds do not require customer approval. * **Outgoing funds.** Withdrawals and transfers out require the customer to authorize them on their device. Grid returns a `payloadToSign` in the quote's `paymentInstructions`; the client signs those bytes with its session signing key and passes the base64 signature as the `Grid-Wallet-Signature` header on `/quotes/{id}/execute`. Only then does Grid release the funds. Sessions are short-lived (15 minutes by default) and bound to a specific device via the client key pair, so a stolen signature can't be replayed from a different device or after the session expires. Standard transaction webhooks fire throughout the lifecycle. See [Transaction lifecycle](/platform-overview/core-concepts/transaction-lifecycle). ## Architecture Three parties participate in every signed action: | Party | Role | | - | - | | **Client** | The customer's device (browser, iOS app, or Android app). Generates the client key pair, runs WebAuthn, holds the session signing key, and signs outbound requests. | | **Integrator backend** | Your server. Holds your Grid API credentials, brokers every call to Grid on behalf of the client, and issues WebAuthn challenges for initial passkey registration. | | **Grid** | Verifies auth credentials, issues sessions bound to the client's public key, and enforces that every account action is authorized. | The client **never** talks to Grid directly. Every request flows client → integrator backend → Grid. ## Auth credentials, client keys, and session signing keys Three distinct pieces of crypto collaborate to authorize actions on the Global Account (withdrawals, credential changes, session revocations, wallet exports, and wallet privacy updates): | Piece | Where it lives | How long it lives | What it proves | | - | - | - | - | | **Auth credential**: passkey, OIDC token, or email OTP | Registered on the account; the passkey itself lives on the authenticator, OIDC on your IdP, OTP in the user's inbox | Until the customer revokes it | *"I am the human who owns this account."* Used to authenticate the user at the start of each session. | | **Client key pair** (P-256) | Generated on the client device for each session-issuing or export request; private key stays in device-local secure storage | One authentication, session refresh, or wallet export | Binds a session or wallet export to the exact device that asked for it. For a session, its private key is the session signing key. For a wallet export, Grid encrypts the credentials to its public key, so only this device can decrypt them. | | **Session signing key** (P-256) | The private key of the client key pair that issued the session; held on the device for the session's lifetime | 15 minutes (default) | *"This specific account action was approved on an authenticated device."* Builds Grid wallet signatures over the `payloadToSign` Grid returns on quotes, credential changes, session refresh/revocation, wallet exports, sign-in email or phone number changes, and wallet privacy updates. | The flow is always the same: verify an auth credential with a fresh client key pair → hold its private key as the short-lived session signing key → build a Grid wallet signature over the `payloadToSign` bytes on the client → pass that signature as the `Grid-Wallet-Signature` header on the request that actually moves funds or changes account state. This applies to withdrawals, adding or removing credentials, refreshing or revoking sessions, exporting the wallet seed, changing the sign-in email or phone number, and updating wallet privacy. ## Prerequisites > [!WARNING] > > Customers who hold a Global Account must be KYC/KYB verified before any account funds can move from or to fiat rails. This quickstart picks up after KYC is complete. > > In sandbox, customers are automatically KYC approved on creation so you can skip straight to account setup. You also need: * A platform configured with `USDB` in its supported currencies. In sandbox, USDB is enabled by default alongside `USD` and `USDC`. * Sandbox or production API credentials with access to the `Embedded Wallet Auth` and `Internal Accounts` endpoints. ```bash theme={null} export GRID_BASE_URL="https://api.lightspark.com/grid/2025-10-13" export GRID_CLIENT_ID="YOUR_SANDBOX_CLIENT_ID" export GRID_CLIENT_SECRET="YOUR_SANDBOX_CLIENT_SECRET" ``` ## Walkthrough The walkthrough below is the happy path: create a customer, find the auto-provisioned account and its default email OTP credential, fund it, and withdraw to a bank account. Each step shows the HTTP request your integrator backend makes on behalf of the client. ### 1. Create a customer Create the customer record. A Global Account is provisioned automatically whenever a customer is created on a platform that has `USDB` in its supported currencies — you don't need to pass it on the customer. ```bash theme={null} curl -X POST "$GRID_BASE_URL/customers" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerType": "INDIVIDUAL", "platformCustomerId": "ind-9f84e0c2", "region": "US", "email": "jane@example.com", "fullName": "Jane Doe", "birthDate": "1990-01-15", "nationality": "US" }' ``` **Response:** `201 Created` with the new `Customer:...` id. In sandbox, the customer is KYC-approved immediately; in production you would now run them through the KYC / KYB flow before any funds can move. ### 2. Find the Global Account When a customer is created on a USDB-enabled platform, Grid automatically provisions a Global Account alongside their other internal accounts. Fetch it by filtering the customer's internal accounts by `type=EMBEDDED_WALLET`. ```bash theme={null} curl -X GET "$GRID_BASE_URL/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001&type=EMBEDDED_WALLET" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response:** ```json theme={null} { "data": [ { "id": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "type": "EMBEDDED_WALLET", "balance": { "amount": 0, "currency": { "code": "USDB", "name": "USDB", "decimals": 6 } }, "totalBalance": { "amount": 0, "currency": { "code": "USDB", "name": "USDB", "decimals": 6 } }, "fundingPaymentInstructions": [], "createdAt": "2026-04-19T12:00:00Z", "updatedAt": "2026-04-19T12:00:00Z" } ], "hasMore": false, "totalCount": 1 } ``` Hold onto the `InternalAccount:...` id — every auth credential is scoped to it. ### 3. Find the default email OTP credential Global Accounts are initialized with an `EMAIL_OTP` credential tied to the customer email on file. Fetch the auth methods for the account and keep the `AuthMethod:...` id for the signing step later in this walkthrough. ```bash theme={null} curl -X GET "$GRID_BASE_URL/auth/credentials?accountId=InternalAccount:019542f5-b3e7-1d02-0000-000000000002" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response:** ```json theme={null} { "data": [ { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000001", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-19T12:00:01Z", "updatedAt": "2026-04-19T12:00:01Z" } ] } ``` You can add passkeys or OAuth credentials later, but adding credentials is itself a signed action. Start with the default email OTP credential to mint the first session signing key. ### 4. Fund the Account Global Accounts behave like any other internal account on the way in — incoming funds do not need the customer's signature. In sandbox, use the sandbox funding endpoint to skip straight to a funded state: ```bash theme={null} curl -X POST "$GRID_BASE_URL/sandbox/internal-accounts/InternalAccount:019542f5-b3e7-1d02-0000-000000000002/fund" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "amount": 1000000000 }' ``` `amount` is in the smallest unit of the account's currency. USDB has 6 decimals, so `1000000000` is 1,000.00 USDB. You will receive an `INCOMING_PAYMENT` webhook when the balance updates. The account now holds 1,000.00 USDB. > [!TIP] > > To fund from another currency (USD ACH, USDC on-chain, etc.), create a quote with `destination.destinationType: "ACCOUNT"` pointing at the Global Account's `InternalAccount` id. The quote's `sourceCurrency` can be any supported platform currency; Grid will convert into USDB on execute. ### 5. Add an external bank account Add the destination the customer wants to withdraw to. This is a standard external account — nothing Global Account-specific. ```bash theme={null} curl -X POST "$GRID_BASE_URL/customers/external-accounts" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "currency": "USD", "platformAccountId": "jane_doe_checking", "accountInfo": { "accountType": "USD_ACCOUNT", "accountNumber": "1234567890", "routingNumber": "021000021", "bankAccountType": "CHECKING", "beneficiary": { "beneficiaryType": "INDIVIDUAL", "fullName": "Jane Doe", "birthDate": "1990-01-15", "nationality": "US", "address": { "line1": "123 Main Street", "city": "San Francisco", "state": "CA", "postalCode": "94105", "country": "US" } } } }' ``` **Response:** `201 Created` with the new `ExternalAccount:...` id. ### 6. Create a withdrawal quote Create a quote with the Global Account as the source. Grid returns a `payloadToSign` in the quote's payment instructions — this is what the client will sign to authorize the transfer. ```bash theme={null} curl -X POST "$GRID_BASE_URL/quotes" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 10000000, "description": "Withdrawal to checking" }' ``` `lockedCurrencyAmount` is in the smallest unit of the locked side's currency. Here the sending currency is USDB (6 decimals), so `10000000` is 10.00 USDB. **Response:** ```json theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000006", "status": "PENDING", "createdAt": "2026-04-19T12:05:00Z", "expiresAt": "2026-04-19T12:10:00Z", "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "sendingCurrency": { "code": "USDB", "name": "USDB", "decimals": 6 }, "receivingCurrency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 }, "totalSendingAmount": 10000000, "totalReceivingAmount": 975, "exchangeRate": 1.0, "feesIncluded": 250000, "platformFeesIncluded": 0, "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000005", "paymentInstructions": [ { "accountOrWalletInfo": { "accountType": "EMBEDDED_WALLET", "payloadToSign": "{\"type\":\"ACTIVITY_TYPE_SIGN_TRANSACTION_V2\",\"timestampMs\":\"1746736509954\",\"organizationId\":\"org_abc123\",\"parameters\":{\"signWith\":\"wallet_abc123def456\",\"unsignedTransaction\":\"ea69b4bf05f775209f26ff0a34a05569180f7936579d5c4af9377ae550194f72\",\"type\":\"TRANSACTION_TYPE_ETHEREUM\"},\"generateAppProofs\":true}" }, "instructionsNotes": "Stamp the payloadToSign byte-for-byte and pass the stamp as the Grid-Wallet-Signature header on execute" } ] } ``` ### 7. Authenticate and sign The customer has an outstanding quote with a `payloadToSign`. Now we need a session signing key to sign it with. With `EMAIL_OTP`, the client generates a TEK (Target Encryption Key) pair, HPKE-encrypts the OTP code, and uses the TEK private key both to complete login and to sign the quote payload. **1. Your backend requests a fresh OTP** Ask Grid to send a fresh OTP email for the default `EMAIL_OTP` credential. The response includes `otpEncryptionTargetBundle` for the secure OTP flow. ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/challenge" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (200):** ```json theme={null} { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000001", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "otpEncryptionTargetBundle": "{\"version\":\"v1.0.0\",\"data\":\"7b22...\",\"dataSignature\":\"3045...\",\"enclaveQuorumPublic\":\"04a1...\"}", "createdAt": "2026-04-19T12:00:01Z", "updatedAt": "2026-04-19T12:05:00Z" } ``` Return `otpEncryptionTargetBundle` to the client. **2. Client encrypts the OTP and verifies** The client generates a fresh [P-256 key pair (the TEK)](client-keys#1-generate-a-client-key-pair), [HPKE-encrypts](client-keys#encrypt-the-otp-code-email_otp-only) `{otp_code, public_key}` under `otpEncryptionTargetBundle`, and sends the encrypted bundle to your backend. `public_key` must be the compressed public key (`02` or `03` prefix). In sandbox, use OTP code `000000`. Your backend calls verify with the encrypted bundle: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "EMAIL_OTP", "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" }' ``` **Response (202):** ```json theme={null} { "type": "EMAIL_OTP", "payloadToSign": "{\"login\":{\"publicKey\":\"02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31\"},\"tokenId\":\"7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21\",\"type\":\"USAGE_TYPE_LOGIN\"}", "requestId": "Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21", "expiresAt": "2026-04-19T12:10:00Z" } ``` Return `payloadToSign` and `requestId` to the client. **3. Client stamps payloadToSign and completes login** The client [stamps](client-keys#2-sign-a-payloadtosign) the exact UTF-8 bytes of `payloadToSign`, unchanged, with the TEK private key and sends the stamp back to your backend. Your backend retries the same request with the stamp: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ -d '{ "type": "EMAIL_OTP", "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" }' ``` **Response (200):** ```json theme={null} { "id": "Session:019542f5-b3e7-1d02-0000-000000000003", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-19T12:05:01Z", "updatedAt": "2026-04-19T12:05:01Z", "expiresAt": "2026-04-19T12:20:01Z" } ``` The TEK public key is now the session API key. The TEK private key **is** the session signing key — the client already has it. **4. Client stamps the quote payload** The client [signs the quote's `payloadToSign`](client-keys#2-sign-a-payloadtosign) with the same TEK private key. Return the full Grid wallet signature to your backend. > [!WARNING] > > Stamp the `payloadToSign` bytes exactly as Grid returned them. Do not parse, re-serialize, trim, or normalize the JSON — the stamp must cover the same bytes Grid's verifier hashes. The session signing key is now valid for 15 minutes, so subsequent account actions within that window (for example, a second withdrawal) can reuse it without another `/challenge` + `/verify` round-trip. ### 8. Execute the quote Call `/execute` with the stamp in the `Grid-Wallet-Signature` header. ```bash theme={null} curl -X POST "$GRID_BASE_URL/quotes/Quote:019542f5-b3e7-1d02-0000-000000000006/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: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" ``` **Response:** ```json theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000006", "status": "PROCESSING", "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000005", "totalSendingAmount": 10000000, "totalReceivingAmount": 975, "feesIncluded": 250000 } ``` The transaction is on its way. You'll receive standard transaction webhooks (`OUTGOING_PAYMENT`) as it settles — see [Transaction lifecycle](/platform-overview/core-concepts/transaction-lifecycle). ## Where to next > [card] [Client keys & signing](./client-keys) > > Generate the P-256 key pair, hold the session signing key, and sign payloads on Web, iOS, and Android. > [card] [Authentication](./authentication) > > OAuth and Email OTP flows, passkey reauthentication, and the full WebAuthn parameter mapping. > [card] [Sessions](./managing-sessions) > > List, refresh, and revoke active sessions. > [card] [Exporting a wallet](./exporting-wallet) > > Let a customer take their wallet seed off Grid. # Global P2P Source: https://agents-docs.lightspark.com/global-p2p/index ![Global P2P hero](https://mintcdn.com/agents-docs/qL8kqlSYbvexR8cg/images/heroes/hero-globalp2p.jpg?fit=max\&auto=format\&n=qL8kqlSYbvexR8cg\&q=85\&s=48f83fa2a148b3f433da31c41601ce8f) With Grid, you can send and receive low cost real-time payments to bank accounts and UMA addresses worldwide through a single, simple API. Grid automatically routes each payment across its network of Grid switches, handling FX, blockchain settlement, and instant banking off-ramps for you. ![](/images/icons/globe.svg) Single API, global reach Grid interacts with the Money Grid to route your payments globally. ![](/images/icons/shield.svg) No Crypto Handling Leverages local instant banking rails and global low latency crypto rails to settle payments in real-time. ![](/images/icons/paper-plane-top-right.svg) Instant Delivery Leverages local instant banking rails and global low latency crypto rails to settle payments in real-time. > [!NOTE] > > For background on the UMA protocol itself, see the UMA Standard documentation: [UMA Standard—Introduction](https://docs.uma.me/uma-standard/introduction). ## Payment Flow **1. Fund Internal Account or prepare for Just-in-Time Funding** You can either prefund an internal account with fiat or receive just-in-time payment instructions as part of the quote. **2. Create Quote** Create a quote to lock exchange rate to the receiving foreign account and parse payment instructions. **3. Execute Quote** Execute the quote or send funds as per payment instructions to initiate the transfer from the internal account to the external bank account. ## Grid Features Users interact with Grid through two main interfaces. > [tab] API > > Programmatic access to create customers, quotes, fund the account, send payments and reconcile via webhooks. > [tab] Dashboard > > Your development and operations team can use the dashboard to monitor payments and webhooks, manage API keys and environments, and troubleshoot with logs. Grid supports the following functionality. ### Onboarding customers Grid has two customer onboarding options - one for non regulated entities where Grid handles the KYC/KYB process and one for regulated entities where you handle the KYC/KYB process. When creating customers, you'll be able to assign a customized UMA address to facilitate sending and receiving UMA payments. ### Funding Payments Grid supports multiple transaction funding options including prefunded accounts and real-time funding. You can prefund an account using several payment rails such as ACH, SEPA Instant, wire transfers, Lightning, and more. With real-time funding, you'll receive payment instructions as part of the quote. Once payment is received by our services, we'll initiate the payment to the receiver. ### Sending & Receiving Payments To send with Grid, you query recipient information and pricing, then execute and fund a quote. Grid resolves the receiver (by UMA or external bank details), returns min/max and an exchange rate, and provides funding instructions. Once funded, Grid handles FX and delivery to the receiving account. To receive with Grid, you expose an UMA or supported account identifier. The platform handles conversion and offramping to the receiver’s account currency and notifies you via webhooks so you can credit the customer. ### Environments Grid supports two environments: **Sandbox** and **Production**. Sandbox mirrors production behavior and webhooks so you can test receiver resolution, quotes and funding instructions, settlement status changes, and full end‑to‑end flows without moving real funds. Production uses live credentials and base URLs for real payments once you’re ready to launch. # External Accounts Source: https://agents-docs.lightspark.com/global-p2p/managing-accounts/external-accounts Register and manage beneficiary bank accounts External accounts are bank accounts, cryptocurrency wallets, or payment destinations outside Grid where you can send funds. Grid supports two types: * **Customer external accounts** - Scoped to individual customers, used for withdrawals and customer-specific payouts * **Platform external accounts** - Scoped to your platform, used for platform-wide operations like receiving funds from external sources > [!NOTE] > > Customer external accounts often require some basic beneficiary information for compliance. > Platform accounts are managed at the organization level. ## Look up bank names with the Discoveries API Some countries require a `bankName` when creating an external account — including the Philippines, Nigeria, South Africa, and others. The `bankName` value must match exactly what Grid expects. Use the [Discoveries API](/api-reference/discoveries/list-available-receiving-institution-names) to retrieve the list of valid bank names for a given country and currency: ```bash cURL theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/discoveries?country=PH¤cy=PHP' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ```json Response theme={null} { "data": [ { "bankName": "BDO Unibank", "displayName": "BDO Unibank", "country": "PH", "currency": "PHP" }, { "bankName": "BPI", "displayName": "Bank of the Philippine Islands", "country": "PH", "currency": "PHP" } ] } ``` Use `bankName` from the response as the `bankName` value when creating an external account. The `displayName` is a human-friendly label you can show to your users. You can filter the list on the client side as the user types to provide a bank search experience. ## Create external accounts by region or wallet > [tab] United States > > **ACH, Wire, RTP** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USD", > "platformAccountId": "user_123_primary_bank", > "accountInfo": { > "accountType": "USD_ACCOUNT", > "accountNumber": "123456789", > "routingNumber": "021000021", > "bankAccountType": "CHECKING", > "bankName": "Chase Bank", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "John Doe", > "birthDate": "1990-01-15", > "nationality": "US", > "address": { > "line1": "123 Main Street", > "city": "San Francisco", > "state": "CA", > "postalCode": "94105", > "country": "US" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankAccountType` is required and must be `CHECKING` or `SAVINGS`. Grid uses it to set the > > ACH transaction code, so a value that does not match the account causes the receiving bank > > to return a notification of change. `routingNumber` must be 9 digits. > > > [!NOTE] > > > > Three further fields apply to the `WIRE` rail and are ignored on ACH, RTP and FedNow: > > `intermediaryBankName` and `intermediaryRoutingNumber`, for a beneficiary bank reachable only > > through a correspondent, and `fiToFiInformation` for bank-to-bank instructions. > [tab] Mexico > > **CLABE/SPEI** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "MXN", > "platformAccountId": "mx_beneficiary_001", > "accountInfo": { > "accountType": "MXN_ACCOUNT", > "clabeNumber": "123456789012345678", > "bankName": "BBVA Mexico", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "María García", > "birthDate": "1985-03-15", > "nationality": "MX", > "address": { > "line1": "Av. Reforma 123", > "city": "Ciudad de México", > "state": "CDMX", > "postalCode": "06600", > "country": "MX" > } > } > } > }' > ``` > [tab] Brazil > > **PIX** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "BRL", > "platformAccountId": "br_pix_001", > "accountInfo": { > "accountType": "BRL_ACCOUNT", > "pixKey": "user@email.com", > "pixKeyType": "EMAIL", > "bankName": "Nubank", > "taxId": "12345678900", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "João Silva", > "birthDate": "1988-07-22", > "nationality": "BR", > "address": { > "line1": "Rua das Flores 456", > "city": "São Paulo", > "state": "SP", > "postalCode": "01234-567", > "country": "BR" > } > } > } > }' > ``` > > > [!TIP] > > > > Key types: `CPF`, `CNPJ`, `EMAIL`, `PHONE`, or `RANDOM` > [tab] Colombia > > **Bank Transfer or Mobile Money (Nequi, Daviplata)** > > Colombia supports two payment rails: bank transfer and mobile money. > > **Bank Transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "COP", > "platformAccountId": "co_bank_001", > "accountInfo": { > "accountType": "COP_ACCOUNT", > "bankName": "Bancolombia", > "accountNumber": "1234567890", > "bankAccountType": "CHECKING", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Juan Carlos García", > "birthDate": "1988-03-15", > "nationality": "CO", > "documentType": "CC", > "documentNumber": "1234567890", > "address": { > "line1": "Carrera 7 #45-23", > "city": "Bogotá", > "postalCode": "110111", > "country": "CO" > } > } > } > }' > ``` > > **Mobile Money (Nequi, Daviplata):** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "COP", > "platformAccountId": "co_mobile_001", > "accountInfo": { > "accountType": "COP_ACCOUNT", > "bankName": "Nequi", > "phoneNumber": "+573001234567", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "María López", > "birthDate": "1992-07-20", > "nationality": "CO", > "documentType": "CC", > "documentNumber": "9876543210", > "address": { > "line1": "Calle 100 #15-30", > "city": "Medellín", > "postalCode": "050021", > "country": "CO" > } > } > } > }' > ``` > > > [!NOTE] > > > > Bank transfer requires `bankName`, `accountNumber`, and `bankAccountType` (CHECKING or SAVINGS). > > Mobile money requires `phoneNumber` in international format (e.g., +573001234567). > > Most Colombian banks require `documentType` and `documentNumber` for KYC matching. > > Document types: `CC` (Cédula de Ciudadanía), `CE` (Cédula de Extranjería), `TI` (Tarjeta de Identidad), `NIT` (Número de Identificación Tributaria), `PP` (Passport). > [tab] El Salvador > > **Bank Transfer or Mobile Money (Tigo Money)** > > El Salvador supports two payment rails: bank transfer and mobile money. > > **Bank Transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USD", > "platformAccountId": "sv_bank_001", > "accountInfo": { > "accountType": "SLV_ACCOUNT", > "bankName": "Banco Cuscatlan", > "accountNumber": "0123456789", > "bankAccountType": "CHECKING", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Carlos Morales", > "birthDate": "1990-05-20", > "nationality": "SV", > "address": { > "line1": "Colonia Escalon 123", > "city": "San Salvador", > "postalCode": "01101", > "country": "SV" > } > } > } > }' > ``` > > **Mobile Money (Tigo Money):** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USD", > "platformAccountId": "sv_mobile_001", > "accountInfo": { > "accountType": "SLV_ACCOUNT", > "phoneNumber": "+50312345678", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Ana Martinez", > "birthDate": "1985-08-15", > "nationality": "SV", > "address": { > "line1": "Avenida Roosevelt 456", > "city": "San Salvador", > "postalCode": "01101", > "country": "SV" > } > } > } > }' > ``` > > > [!NOTE] > > > > Bank transfer requires `bankName`, `accountNumber`, and `bankAccountType` (CHECKING or SAVINGS). > > Mobile money requires `phoneNumber` in international format (e.g., +50312345678). > [tab] Europe > > **EUR Account (IBAN)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "EUR", > "platformAccountId": "eu_iban_001", > "accountInfo": { > "accountType": "EUR_ACCOUNT", > "iban": "DE89370400440532013000", > "bankName": "Deutsche Bank", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Hans Schmidt", > "birthDate": "1982-11-08", > "nationality": "DE", > "address": { > "line1": "Hauptstraße 789", > "city": "Berlin", > "state": "Berlin", > "postalCode": "10115", > "country": "DE" > } > } > } > }' > ``` > > When you omit `swiftCode`, Grid derives it from the IBAN when possible. Provide > it when automatic derivation is unavailable. > [tab] Philippines > > **PHP Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "PHP", > "platformAccountId": "ph_bank_001", > "accountInfo": { > "accountType": "PHP_ACCOUNT", > "bankName": "BDO Unibank", > "accountNumber": "001234567890", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Maria Santos", > "birthDate": "1995-04-10", > "nationality": "PH", > "address": { > "line1": "123 Rizal Avenue", > "city": "Manila", > "state": "Metro Manila", > "postalCode": "1000", > "country": "PH" > } > } > } > }' > ``` > > > [!NOTE] > > > > Account number must be 8-16 digits. > [tab] United Kingdom > > **GBP Faster Payments** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "GBP", > "platformAccountId": "gb_bank_001", > "accountInfo": { > "accountType": "GBP_ACCOUNT", > "sortCode": "123456", > "accountNumber": "12345678", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "James Smith", > "birthDate": "1985-09-03", > "nationality": "GB", > "address": { > "line1": "10 Downing Street", > "city": "London", > "postalCode": "SW1A 2AA", > "country": "GB" > } > } > } > }' > ``` > > > [!NOTE] > > > > Sort code must be 6 digits. Account number must be 8 digits. Address is required for GBP individual beneficiaries. > [tab] India > > **UPI, NEFT, or RTGS** > > India supports three payment rails: UPI (instant), NEFT (near-real-time), and RTGS (real-time, for large transfers). > > **UPI:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "INR", > "platformAccountId": "in_upi_001", > "accountInfo": { > "accountType": "INR_ACCOUNT", > "vpa": "user@okbank", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Priya Sharma", > "birthDate": "1991-05-14", > "nationality": "IN", > "address": { > "line1": "123 MG Road", > "city": "Mumbai", > "state": "Maharashtra", > "postalCode": "400001", > "country": "IN" > } > } > } > }' > ``` > > **NEFT / RTGS (bank transfer):** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "INR", > "platformAccountId": "in_neft_001", > "accountInfo": { > "accountType": "INR_ACCOUNT", > "accountNumber": "000111222333", > "ifsc": "HDFC0001234", > "rail": "NEFT", > "bankName": "HDFC Bank", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Priya Sharma", > "birthDate": "1991-05-14", > "nationality": "IN", > "address": { > "line1": "123 MG Road", > "city": "Mumbai", > "state": "Maharashtra", > "postalCode": "400001", > "country": "IN" > } > } > } > }' > ``` > > > [!NOTE] > > > > UPI requires `vpa`. NEFT and RTGS require `accountNumber` (9-18 digits), `ifsc` > > (11-character branch code), and `rail` (`NEFT` or `RTGS`). `bankName` is optional > > on both. Do not send `paymentRails` — Grid returns it on the created account. > [tab] Nigeria > > **NGN Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "NGN", > "platformAccountId": "ng_bank_001", > "accountInfo": { > "accountType": "NGN_ACCOUNT", > "accountNumber": "0123456789", > "bankName": "First Bank of Nigeria", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Chukwuemeka Okonkwo", > "birthDate": "1990-06-20", > "nationality": "NG", > "address": { > "line1": "15 Marina Street", > "city": "Lagos", > "state": "Lagos", > "postalCode": "100001", > "country": "NG" > } > } > } > }' > ``` > > > [!NOTE] > > > > Account number must be exactly 10 digits. > [tab] South Africa > > **ZAR Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "ZAR", > "platformAccountId": "za_bank_001", > "accountInfo": { > "accountType": "ZAR_ACCOUNT", > "accountNumber": "1234567890", > "bankName": "Standard Bank", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Thabo Mbeki", > "birthDate": "1985-04-12", > "nationality": "ZA", > "address": { > "line1": "42 Nelson Mandela Drive", > "city": "Johannesburg", > "state": "Gauteng", > "postalCode": "2000", > "country": "ZA" > } > } > } > }' > ``` > > > [!NOTE] > > > > Account number must be 9-13 digits. > [tab] Kenya > > **KES Mobile Money (M-PESA)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "KES", > "platformAccountId": "ke_mpesa_001", > "accountInfo": { > "accountType": "KES_ACCOUNT", > "phoneNumber": "+254712345678", > "provider": "M-PESA", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Wanjiku Kamau", > "birthDate": "1990-08-20", > "nationality": "KE", > "address": { > "line1": "Kenyatta Avenue 15", > "city": "Nairobi", > "postalCode": "00100", > "country": "KE" > } > } > } > }' > ``` > > > [!NOTE] > > > > Phone number must be in format `+254XXXXXXXXX` (9 digits after country code). Provider must be `M-PESA`. > [tab] Tanzania > > **TZS Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "TZS", > "platformAccountId": "tz_mobile_001", > "accountInfo": { > "accountType": "TZS_ACCOUNT", > "phoneNumber": "+255712345678", > "provider": "VODACOM", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Juma Hassan", > "birthDate": "1988-02-14", > "nationality": "TZ", > "address": { > "line1": "Samora Avenue 28", > "city": "Dar es Salaam", > "postalCode": "11101", > "country": "TZ" > } > } > } > }' > ``` > > > [!NOTE] > > > > Phone number must be in format `+255XXXXXXXXX` (9 digits after country code). Providers: `AIRTEL` or `VODACOM`. > [tab] Zambia > > **ZMW Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "ZMW", > "platformAccountId": "zm_mobile_001", > "accountInfo": { > "accountType": "ZMW_ACCOUNT", > "phoneNumber": "+260971234567", > "provider": "MTN", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Mwila Chanda", > "birthDate": "1992-11-05", > "nationality": "ZM", > "address": { > "line1": "Cairo Road 100", > "city": "Lusaka", > "postalCode": "10101", > "country": "ZM" > } > } > } > }' > ``` > > > [!NOTE] > > > > Phone number must be in format `+260XXXXXXXXX` (9 digits after country code). Providers: `TNM`, `AIRTEL`, `ZAMTEL`, or `MTN`. > [tab] Rwanda > > **RWF Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "RWF", > "platformAccountId": "rw_mobile_001", > "accountInfo": { > "accountType": "RWF_ACCOUNT", > "phoneNumber": "+250781234567", > "provider": "MTN", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Uwimana Diane", > "birthDate": "1995-06-30", > "nationality": "RW", > "address": { > "line1": "KN 5 Avenue", > "city": "Kigali", > "postalCode": "00100", > "country": "RW" > } > } > } > }' > ``` > > > [!NOTE] > > > > Phone number must be in format `+250XXXXXXXXX` (9 digits after country code). Providers: `MTN` or `AIRTEL`. > [tab] Malawi > > **MWK Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "MWK", > "platformAccountId": "mw_mobile_001", > "accountInfo": { > "accountType": "MWK_ACCOUNT", > "phoneNumber": "+265991234567", > "provider": "AIRTEL", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Chimwemwe Banda", > "birthDate": "1993-03-18", > "nationality": "MW", > "address": { > "line1": "Kamuzu Procession Road", > "city": "Lilongwe", > "postalCode": "00100", > "country": "MW" > } > } > } > }' > ``` > > > [!NOTE] > > > > Phone number must be in format `+265XXXXXXXXX` (9 digits after country code). Providers: `AIRTEL` or `TNM`. > [tab] Uganda > > **UGX Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "UGX", > "platformAccountId": "ug_mobile_001", > "accountInfo": { > "accountType": "UGX_ACCOUNT", > "phoneNumber": "+256701234567", > "provider": "MTN", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Nakato Sarah", > "birthDate": "1990-07-25", > "nationality": "UG", > "address": { > "line1": "Kampala Road 45", > "city": "Kampala", > "postalCode": "00100", > "country": "UG" > } > } > } > }' > ``` > > > [!NOTE] > > > > Phone number must be in format `+256XXXXXXXXX` (9 digits after country code). Providers: `MTN` or `AIRTEL`. > [tab] West Africa > > **XOF Mobile Money (Senegal, Benin, Ivory Coast)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "XOF", > "platformAccountId": "xof_mobile_001", > "accountInfo": { > "accountType": "XOF_ACCOUNT", > "phoneNumber": "+221781234567", > "provider": "ORANGE", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Mamadou Diallo", > "birthDate": "1988-11-12", > "nationality": "SN", > "address": { > "line1": "Avenue Cheikh Anta Diop", > "city": "Dakar", > "postalCode": "10000", > "country": "SN" > } > } > } > }' > ``` > > > [!NOTE] > > > > Supported countries and phone formats: > > > > * Senegal (+221): `+221XXXXXXXXX` (9 digits after country code) > > * Ivory Coast (+225): `+225XXXXXXXXXX` (10 digits after country code) > > * Benin (+229): `+229XXXXXXXX` or `+229XXXXXXXXX` (8–9 digits after country code) > [tab] Canada > > **CAD Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "CAD", > "platformAccountId": "ca_bank_001", > "accountInfo": { > "accountType": "CAD_ACCOUNT", > "bankCode": "001", > "branchCode": "00012", > "accountNumber": "1234567", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Emily Thompson", > "birthDate": "1988-09-12", > "nationality": "CA", > "address": { > "line1": "456 Queen Street West", > "city": "Toronto", > "state": "ON", > "postalCode": "M5V 2B3", > "country": "CA" > } > } > } > }' > ``` > > > [!NOTE] > > > > Bank code is 3 digits, branch code is 5 digits, account number is 7-12 digits. > [tab] United Arab Emirates > > **Bank Transfer (IBAN)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "AED", > "platformAccountId": "ae_bank_001", > "accountInfo": { > "accountType": "AED_ACCOUNT", > "iban": "AE070331234567890123456", > "swiftCode": "EBILAEAD", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Fatima Al Mansoori", > "birthDate": "1990-01-15", > "nationality": "AE", > "address": { > "line1": "Sheikh Zayed Road 12", > "city": "Dubai", > "postalCode": "00000", > "country": "AE" > } > } > } > }' > ``` > > > [!NOTE] > > > > `iban` is required and must be a 23-character UAE IBAN starting with `AE`. `swiftCode` is optional. An `address` is required on the beneficiary. > [tab] Israel > > **Bank Transfer (IBAN)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "ILS", > "platformAccountId": "il_bank_001", > "accountInfo": { > "accountType": "ILS_ACCOUNT", > "iban": "IL620108000000099999999", > "bankName": "Bank Leumi", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Noa Levi", > "birthDate": "1990-01-15", > "nationality": "IL", > "address": { > "line1": "Rothschild Boulevard 12", > "city": "Tel Aviv", > "postalCode": "6688112", > "country": "IL" > } > } > } > }' > ``` > > > [!NOTE] > > > > `iban` is required and must be a 23-character Israeli IBAN starting with `IL`. `bankName` is required. Business beneficiaries (`beneficiaryType: "BUSINESS"`) require `legalName` and `address`. > [tab] Bangladesh > > **Bank Transfer or Mobile Money (bKash, Nagad)** > > **Bank transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "BDT", > "platformAccountId": "bd_bank_001", > "accountInfo": { > "accountType": "BDT_ACCOUNT", > "bankName": "BRAC Bank", > "accountNumber": "1234567890123", > "branchCode": "12345", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Rahim Chowdhury", > "birthDate": "1990-01-15", > "nationality": "BD", > "address": { > "line1": "12 Gulshan Avenue", > "city": "Dhaka", > "postalCode": "1212", > "country": "BD" > } > } > } > }' > ``` > > **Mobile money:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "BDT", > "platformAccountId": "bd_mobile_001", > "accountInfo": { > "accountType": "BDT_ACCOUNT", > "bankName": "bKash", > "phoneNumber": "+8801712345678", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Rahim Chowdhury", > "birthDate": "1990-01-15", > "nationality": "BD", > "address": { > "line1": "12 Gulshan Avenue", > "city": "Dhaka", > "postalCode": "1212", > "country": "BD" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName` is always required. Bank transfer additionally uses `accountNumber` and optionally `branchCode`; mobile money uses `phoneNumber`. `swiftCode` is optional on bank transfers. > [tab] Botswana > > **Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "BWP", > "platformAccountId": "bw_mobile_001", > "accountInfo": { > "accountType": "BWP_ACCOUNT", > "phoneNumber": "+26771234567", > "provider": "Orange", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Kagiso Molefe", > "birthDate": "1990-01-15", > "nationality": "BW", > "address": { > "line1": "Plot 123 Main Mall", > "city": "Gaborone", > "postalCode": "00000", > "country": "BW" > } > } > } > }' > ``` > > > [!NOTE] > > > > Both `phoneNumber` (international format) and `provider` are required. > [tab] China > > **AliPay / WeChat Pay or Bank Transfer** > > **Bank transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "CNY", > "platformAccountId": "cn_bank_001", > "accountInfo": { > "accountType": "CNY_ACCOUNT", > "bankName": "Industrial and Commercial Bank of China", > "accountNumber": "6217000010000000001", > "beneficiary": { > "beneficiaryType": "BUSINESS", > "legalName": "Shanghai Trading Co Ltd", > "registrationNumber": "91110000000000000X", > "address": { > "line1": "1 Nanjing Road", > "city": "Shanghai", > "postalCode": "200000", > "country": "CN" > } > } > } > }' > ``` > > **AliPay / WeChat Pay:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "CNY", > "platformAccountId": "cn_mobile_001", > "accountInfo": { > "accountType": "CNY_ACCOUNT", > "bankName": "AliPay", > "phoneNumber": "+8613812345678", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Wei Zhang", > "birthDate": "1990-01-15", > "nationality": "CN", > "address": { > "line1": "1 Nanjing Road", > "city": "Shanghai", > "postalCode": "200000", > "country": "CN" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName` is always required. For a wallet payout it selects the wallet and must be > > exactly `AliPay` or `WechatPay`, paired with `phoneNumber`. Bank transfer uses > > `accountNumber` with the beneficiary bank's name, and is business-to-business only — > > send `beneficiaryType: "BUSINESS"`. A bank transfer payout also needs supporting > > documents. See [Supporting > > documents](/payouts-and-b2b/payment-flow/send-payment#supporting-documents). > [tab] Egypt > > **Bank Transfer or Mobile Money** > > **Bank transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "EGP", > "platformAccountId": "eg_bank_001", > "accountInfo": { > "accountType": "EGP_ACCOUNT", > "bankName": "National Bank of Egypt", > "iban": "EG380019000500000000263180002", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Nour Hassan", > "birthDate": "1990-01-15", > "nationality": "EG", > "address": { > "line1": "15 Tahrir Square", > "city": "Cairo", > "postalCode": "11511", > "country": "EG" > } > } > } > }' > ``` > > **Mobile money:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "EGP", > "platformAccountId": "eg_mobile_001", > "accountInfo": { > "accountType": "EGP_ACCOUNT", > "bankName": "Vodafone Cash", > "phoneNumber": "+201012345678", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Nour Hassan", > "birthDate": "1990-01-15", > "nationality": "EG", > "address": { > "line1": "15 Tahrir Square", > "city": "Cairo", > "postalCode": "11511", > "country": "EG" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName` is always required. Bank transfer uses `iban`, a 29-character Egyptian IBAN starting with `EG`; mobile money uses `phoneNumber`. > [tab] Ghana > > **Bank Transfer or Mobile Money (MTN, Vodafone)** > > **Bank transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "GHS", > "platformAccountId": "gh_bank_001", > "accountInfo": { > "accountType": "GHS_ACCOUNT", > "bankName": "GCB Bank", > "accountNumber": "1234567890", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Kwame Mensah", > "birthDate": "1990-01-15", > "nationality": "GH", > "address": { > "line1": "23 Independence Avenue", > "city": "Accra", > "postalCode": "00233", > "country": "GH" > } > } > } > }' > ``` > > **Mobile money:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "GHS", > "platformAccountId": "gh_mobile_001", > "accountInfo": { > "accountType": "GHS_ACCOUNT", > "bankName": "MTN Mobile Money", > "phoneNumber": "+233241234567", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Kwame Mensah", > "birthDate": "1990-01-15", > "nationality": "GH", > "address": { > "line1": "23 Independence Avenue", > "city": "Accra", > "postalCode": "00233", > "country": "GH" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName` is always required. Bank transfer uses `accountNumber`; mobile money uses `phoneNumber`. > [tab] Guatemala > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "GTQ", > "platformAccountId": "gt_bank_001", > "accountInfo": { > "accountType": "GTQ_ACCOUNT", > "accountNumber": "1234567890", > "bankAccountType": "CHECKING", > "bankName": "Banco GYT Continental", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Ana Lucía Ramírez", > "birthDate": "1990-01-15", > "nationality": "GT", > "phoneNumber": "+50251234567", > "countryOfResidence": "GT", > "address": { > "line1": "5a Avenida 12-34 Zona 10", > "city": "Guatemala City", > "postalCode": "01010", > "country": "GT" > } > } > } > }' > ``` > > > [!NOTE] > > > > `accountNumber`, `bankAccountType` (`CHECKING` or `SAVINGS`) and `bankName` are all required. The beneficiary additionally requires `phoneNumber` and `countryOfResidence`. > [tab] Haiti > > **Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "HTG", > "platformAccountId": "ht_mobile_001", > "accountInfo": { > "accountType": "HTG_ACCOUNT", > "phoneNumber": "+50934567890", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Marie Pierre-Louis", > "birthDate": "1990-01-15", > "nationality": "HT", > "address": { > "line1": "45 Rue Capois", > "city": "Port-au-Prince", > "postalCode": "6110", > "country": "HT" > } > } > } > }' > ``` > > > [!NOTE] > > > > `phoneNumber` in international format is the only required account field. > [tab] Jamaica > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "JMD", > "platformAccountId": "jm_bank_001", > "accountInfo": { > "accountType": "JMD_ACCOUNT", > "accountNumber": "1234567890", > "branchCode": "12345", > "bankAccountType": "CHECKING", > "bankName": "Bank of Nova Scotia", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Andre Campbell", > "birthDate": "1990-01-15", > "nationality": "JM", > "phoneNumber": "+18761234567", > "address": { > "line1": "10 Knutsford Boulevard", > "city": "Kingston", > "postalCode": "00000", > "country": "JM" > } > } > } > }' > ``` > > > [!NOTE] > > > > `accountNumber`, `branchCode` (5 digits), `bankAccountType` (`CHECKING` or `SAVINGS`) and `bankName` are all required. The beneficiary additionally requires `phoneNumber` and an `address`. > [tab] Pakistan > > **Bank Transfer or Mobile Money (JazzCash, Easypaisa)** > > **Bank transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "PKR", > "platformAccountId": "pk_bank_001", > "accountInfo": { > "accountType": "PKR_ACCOUNT", > "bankName": "HBL", > "accountNumber": "1234567890123456", > "iban": "PK36SCBL0000001123456702", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Ayesha Khan", > "birthDate": "1990-01-15", > "nationality": "PK", > "address": { > "line1": "12 Clifton Block 5", > "city": "Karachi", > "postalCode": "75600", > "country": "PK" > } > } > } > }' > ``` > > **Mobile money:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "PKR", > "platformAccountId": "pk_mobile_001", > "accountInfo": { > "accountType": "PKR_ACCOUNT", > "bankName": "JazzCash", > "phoneNumber": "+923001234567", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Ayesha Khan", > "birthDate": "1990-01-15", > "nationality": "PK", > "address": { > "line1": "12 Clifton Block 5", > "city": "Karachi", > "postalCode": "75600", > "country": "PK" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName` is always required. Bank transfer uses `accountNumber`, and optionally `iban` (24 characters, starting with `PK`); mobile money uses `phoneNumber`. > [tab] Central Africa > > **Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "XAF", > "platformAccountId": "xaf_mobile_001", > "accountInfo": { > "accountType": "XAF_ACCOUNT", > "phoneNumber": "+237671234567", > "provider": "MTN", > "region": "CM", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Achille Nkeng", > "birthDate": "1990-01-15", > "nationality": "CM", > "address": { > "line1": "Rue Joss 20", > "city": "Douala", > "postalCode": "00237", > "country": "CM" > } > } > } > }' > ``` > > > [!NOTE] > > > > `phoneNumber`, `provider` and `region` are all required. `region` must be `CM` (Cameroon) or `CG` (Republic of the Congo). > [tab] Denmark > > **Bank Transfer (IBAN)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "DKK", > "platformAccountId": "dk_bank_001", > "accountInfo": { > "accountType": "DKK_ACCOUNT", > "iban": "DK5000400040116243", > "swiftCode": "DABADKKK", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Lars Jensen", > "birthDate": "1990-01-15", > "nationality": "DK", > "address": { > "line1": "Vesterbrogade 10", > "city": "Copenhagen", > "postalCode": "1620", > "country": "DK" > } > } > } > }' > ``` > > > [!NOTE] > > > > `iban` is required; `swiftCode` is optional. > [tab] Hong Kong > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "HKD", > "platformAccountId": "hk_bank_001", > "accountInfo": { > "accountType": "HKD_ACCOUNT", > "bankName": "HSBC Hong Kong", > "accountNumber": "123456789012", > "swiftCode": "HSBCHKHHHKH", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Emily Chan", > "birthDate": "1990-01-15", > "nationality": "HK", > "address": { > "line1": "1 Queen's Road Central", > "city": "Hong Kong", > "postalCode": "00000", > "country": "HK" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName`, `accountNumber` and `swiftCode` are all required. > [tab] Indonesia > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "IDR", > "platformAccountId": "id_bank_001", > "accountInfo": { > "accountType": "IDR_ACCOUNT", > "bankName": "Bank Central Asia", > "accountNumber": "1234567890", > "swiftCode": "CENAIDJA", > "phoneNumber": "+6281234567890", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Siti Rahayu", > "birthDate": "1990-01-15", > "nationality": "ID", > "address": { > "line1": "Jl. Sudirman 45", > "city": "Jakarta", > "postalCode": "10210", > "country": "ID" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName`, `accountNumber`, `swiftCode` and `phoneNumber` are all required. > [tab] Malaysia > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "MYR", > "platformAccountId": "my_bank_001", > "accountInfo": { > "accountType": "MYR_ACCOUNT", > "bankName": "Maybank", > "accountNumber": "1234567890", > "swiftCode": "MABORUMMYYY", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Nurul Aisyah", > "birthDate": "1990-01-15", > "nationality": "MY", > "address": { > "line1": "Jalan Ampang 88", > "city": "Kuala Lumpur", > "postalCode": "50450", > "country": "MY" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName`, `accountNumber` and `swiftCode` are all required. > [tab] Singapore > > **Bank Transfer (PayNow, FAST)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "SGD", > "platformAccountId": "sg_bank_001", > "accountInfo": { > "accountType": "SGD_ACCOUNT", > "bankName": "DBS Bank Ltd", > "accountNumber": "0123456789", > "swiftCode": "DBSSSGSG", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Wei Lin Tan", > "birthDate": "1990-01-15", > "nationality": "SG", > "address": { > "line1": "12 Marina Boulevard", > "city": "Singapore", > "postalCode": "018982", > "country": "SG" > } > } > } > }' > ``` > > > [!NOTE] > > > > `accountNumber` and `swiftCode` are required; `bankName` is optional. > [tab] Thailand > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "THB", > "platformAccountId": "th_bank_001", > "accountInfo": { > "accountType": "THB_ACCOUNT", > "bankName": "Bangkok Bank", > "accountNumber": "1234567890", > "swiftCode": "BKKBTHBK", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Somchai Prasert", > "birthDate": "1990-01-15", > "nationality": "TH", > "address": { > "line1": "333 Silom Road", > "city": "Bangkok", > "postalCode": "10500", > "country": "TH" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName`, `accountNumber` and `swiftCode` are all required. > [tab] Vietnam > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "VND", > "platformAccountId": "vn_bank_001", > "accountInfo": { > "accountType": "VND_ACCOUNT", > "bankName": "Vietcombank", > "accountNumber": "1234567890", > "swiftCode": "BFTVVNVX", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Nguyen Thi Lan", > "birthDate": "1990-01-15", > "nationality": "VN", > "address": { > "line1": "22 Le Loi", > "city": "Ho Chi Minh City", > "postalCode": "70000", > "country": "VN" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName`, `accountNumber` and `swiftCode` are all required. > [tab] SWIFT (International) > > **SWIFT / International Wire** > > Use a `SWIFT_ACCOUNT` external account to send payouts to bank accounts in countries or currencies where Grid doesn't offer a local payment rail. Provide the bank's SWIFT/BIC code and the beneficiary's account number (or IBAN for IBAN-based corridors). > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USD", > "platformAccountId": "swift_usd_001", > "accountInfo": { > "accountType": "SWIFT_ACCOUNT", > "country": "NG", > "swiftCode": "GTBINGLA", > "bankName": "Guaranty Trust Bank", > "accountNumber": "1234567890", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Adaeze Okafor", > "birthDate": "1991-03-22", > "nationality": "NG", > "address": { > "line1": "15 Broad Street", > "city": "Lagos", > "state": "Lagos", > "postalCode": "101233", > "country": "NG" > } > } > } > }' > ``` > > > [!NOTE] > > > > `currency` is the currency the destination account holds, and `country` is where the bank > > account is located — they can differ. Provide `accountNumber` for most corridors; use `iban` > > instead for IBAN-only corridors (e.g. GB, BR). > [tab] Cryptocurrency > > **Bitcoin Lightning (Spark Wallet)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "BTC", > "platformAccountId": "btc_spark_001", > "accountInfo": { > "accountType": "SPARK_WALLET", > "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu" > } > }' > ``` > > **Ethereum L1** > > Supported assets: USDC and USDT. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDC", > "platformAccountId": "eth_usdc_001", > "accountInfo": { > "accountType": "ETHEREUM_WALLET", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > }' > ``` > > **Base** > > Supported assets: USDC. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDC", > "platformAccountId": "base_usdc_001", > "accountInfo": { > "accountType": "BASE_WALLET", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > }' > ``` > > **Polygon** > > Supported assets: USDC. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDC", > "platformAccountId": "polygon_usdc_001", > "accountInfo": { > "accountType": "POLYGON_WALLET", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > }' > ``` > > **Solana** > > Supported assets: USDC. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDC", > "platformAccountId": "sol_usdc_001", > "accountInfo": { > "accountType": "SOLANA_WALLET", > "address": "4Nd1m6Qkq7RfKuE5vQ9qP9Tn6H94Ueqb4xXHzsAbd8Wg" > } > }' > ``` > > **Tron** > > Supported assets: USDT. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDT", > "platformAccountId": "tron_usdt_001", > "accountInfo": { > "accountType": "TRON_WALLET", > "address": "TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL" > } > }' > ``` > > **Plasma** > > Supported assets: USDT. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDT", > "platformAccountId": "plasma_usdt_001", > "accountInfo": { > "accountType": "PLASMA_WALLET", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > }' > ``` > > **Arbitrum** > > Supported assets: USDC, USDT. > > ```bash cURL USDC 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDC", > "platformAccountId": "arbitrum_usdc_001", > "accountInfo": { > "accountType": "ARBITRUM_WALLET", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > }' > ``` > > ```bash cURL USDT 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDT", > "platformAccountId": "arbitrum_usdt_001", > "accountInfo": { > "accountType": "ARBITRUM_WALLET", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > }' > ``` > > **Lightning** > > Exactly one of `lightningAddress`, `invoice` (a single-use BOLT11 invoice) or `bolt12` > (a reusable offer) must be provided. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "BTC", > "platformAccountId": "btc_lightning_001", > "accountInfo": { > "accountType": "LIGHTNING", > "lightningAddress": "john.doe@lightningwallet.com" > } > }' > ``` > > > [!NOTE] > > > > Crypto wallets don't require beneficiary information. Send only assets on the > > named network — EVM addresses look identical across Ethereum, Base, Polygon, > > Plasma, and Arbitrum, and funds sent on the wrong network are unrecoverable. > [!TIP] > > Use `platformAccountId` to tie your internal id with the external account. **Sample Response:** ```json theme={null} { "id": "ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "status": "ACTIVE", "currency": "USD", "platformAccountId": "user_123_primary_bank", "accountInfo": { "accountType": "USD_ACCOUNT", "accountNumber": "123456789", "routingNumber": "021000021", "bankAccountType": "CHECKING", "bankName": "Chase Bank", "beneficiary": { "beneficiaryType": "INDIVIDUAL", "fullName": "John Doe", "birthDate": "1990-01-15", "nationality": "US", "address": { "line1": "123 Main Street", "city": "San Francisco", "state": "CA", "postalCode": "94105", "country": "US" } } } } ``` ### Business beneficiaries For business accounts, include business information: ```json theme={null} { "currency": "USD", "platformAccountId": "acme_corp_account", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "accountInfo": { "accountType": "USD_ACCOUNT", "accountNumber": "987654321", "routingNumber": "021000021", "bankAccountType": "CHECKING", "bankName": "Chase Bank", "beneficiary": { "beneficiaryType": "BUSINESS", "businessInfo": { "legalName": "Acme Corporation, Inc.", "taxId": "EIN-987654321" }, "address": { "line1": "456 Business Ave", "city": "New York", "state": "NY", "postalCode": "10001", "country": "US" } } } } ``` ## Minimum required beneficiary fields The following tables show the minimum required fields for individual and business beneficiaries. All other fields are optional but recommended for faster compliance review. ### Individual beneficiaries | Country | Required Fields | Required Address Fields | | - | - | - | | US (USD) | `beneficiaryType`, `fullName` | `line1`, `city`, `postalCode`, `country` | | Mexico (MXN) | `beneficiaryType`, `fullName` | None (address optional) | | Brazil (BRL) | `beneficiaryType`, `fullName` | None (address optional) | | Philippines (PHP) | `beneficiaryType`, `fullName` | None (address optional) | | United Kingdom (GBP) | `beneficiaryType`, `fullName`, `address` | `line1`, `city`, `postalCode`, `country` | | Europe (EUR) | `beneficiaryType`, `fullName`, `address` | `line1`, `city`, `postalCode`, `country` | > [!NOTE] > > While only the fields listed above are strictly required, providing additional information like `birthDate`, `nationality`, and `address` can reduce the likelihood of false positive compliance checks and increase transaction success rates. ### Business beneficiaries For business beneficiaries, the required fields vary by destination currency: | Currency | Required fields | | - | - | | USD | `legalName`, `address` | | MXN | `legalName`, `address` | | EUR | `legalName`, `address`, `registrationNumber` | | GBP, INR, BRL, DKK, PHP, HKD, IDR, MYR, SGD, THB, VND, AED, ILS | `legalName`, `address` | | NGN, ZAR, KES, TZS, RWF, ZMW, UGX, BWP, MWK, XOF, XAF | `legalName`, `address`, `registrationNumber`, `taxId` | > [!NOTE] > > When sending to GBP, INR, BRL, DKK, PHP, HKD, IDR, MYR, SGD, THB, VND, AED, or ILS, business senders (originators) must also provide `registrationNumber`. > [!NOTE] > > `registrationNumber` is the business's official registration or incorporation number (e.g., EIN in the US, CNPJ in Brazil, company registration number in the UK). `taxId` is the business's tax identification number where it differs from the registration number. ## Account status Beneficiary data may be reviewed for risk and compliance. Only `ACTIVE` accounts can receive payments. Updates to account data may trigger account re-review. | Status | Description | | - | - | | `PENDING` | Created, awaiting verification | | `ACTIVE` | Verified and ready for transactions | | `UNDER_REVIEW` | Additional review required | | `INACTIVE` | Disabled, cannot be used | ## Listing external accounts ### List customer accounts ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/external-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ### List platform accounts For platform-wide operations, list all platform-level external accounts: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/platform/external-accounts' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [!NOTE] > > Platform external accounts are used for platform-wide operations like > depositing funds from external sources. ## Retrieving a single external account Fetch one external account by its system-generated ID: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/external-accounts/ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response:** ```json theme={null} { "id": "ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "status": "ACTIVE", "currency": "USD", "platformAccountId": "acc_123456789", "accountInfo": { "accountType": "USD_ACCOUNT", "accountNumber": "1234567890", "routingNumber": "021000021", "bankName": "Chase Bank", "bankAccountType": "CHECKING", "paymentRails": ["ACH", "WIRE", "RTP", "FEDNOW"], "beneficiary": { "beneficiaryType": "INDIVIDUAL", "fullName": "Jane Doe" } } } ``` > [!NOTE] > > `paymentRails` reports the rails Grid selected for the account. It is > returned on the account and is not something you send when creating one. Returns `404` if no external account matches the ID. ## Deleting an external account Delete an external account by its system-generated ID: ```bash theme={null} curl -X DELETE 'https://api.lightspark.com/grid/2025-10-13/customers/external-accounts/ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Returns `204 No Content` on success. > [!WARNING] > > An account that is currently a **trusted beneficiary** for Strong Customer > Authentication cannot be deleted — the request fails with `409` and code > `BENEFICIARY_TRUSTED`. Untrust it first via `POST > /customers/external-accounts/{externalAccountId}/untrust` and its > `/confirm`, then delete. ## Best practices > [accordion] Validate account information > > Validate account details before submission: > > ```javascript theme={null} > // US accounts: 9-digit routing, 4-17 digit account number > if (!/^\d{9}$/.test(routingNumber)) { > throw new Error("Invalid routing number"); > } > > // CLABE: exactly 18 digits > if (!/^\d{18}$/.test(clabeNumber)) { > throw new Error("Invalid CLABE number"); > } > > // NGN: exactly 10-digit account number > if (!/^\d{10}$/.test(ngnAccountNumber)) { > throw new Error("Invalid Nigerian account number"); > } > > // CAD: 3-digit bank code, 5-digit branch code, 7-12 digit account number > if (!/^\d{3}$/.test(bankCode)) { > throw new Error("Invalid bank code"); > } > if (!/^\d{5}$/.test(branchCode)) { > throw new Error("Invalid branch code"); > } > if (!/^\d{7,12}$/.test(cadAccountNumber)) { > throw new Error("Invalid Canadian account number"); > } > > // PHP: 8-16 digit account number > if (!/^\d{8,16}$/.test(phpAccountNumber)) { > throw new Error("Invalid Philippine account number"); > } > > // GBP: 6-digit sort code, 8-digit account number > if (!/^\d{6}$/.test(sortCode)) { > throw new Error("Invalid sort code"); > } > if (!/^\d{8}$/.test(gbpAccountNumber)) { > throw new Error("Invalid UK account number"); > } > > // ZAR: 9-13 digit account number > if (!/^\d{9,13}$/.test(zarAccountNumber)) { > throw new Error("Invalid South African account number"); > } > > // African mobile money phone numbers > if (!/^\+254\d{9}$/.test(kesPhoneNumber)) { > throw new Error("Invalid Kenyan phone number"); > } > if (!/^\+255\d{9}$/.test(tzsPhoneNumber)) { > throw new Error("Invalid Tanzanian phone number"); > } > if (!/^\+260\d{9}$/.test(zmwPhoneNumber)) { > throw new Error("Invalid Zambian phone number"); > } > if (!/^\+250\d{9}$/.test(rwfPhoneNumber)) { > throw new Error("Invalid Rwandan phone number"); > } > if (!/^\+265\d{9}$/.test(mwkPhoneNumber)) { > throw new Error("Invalid Malawian phone number"); > } > if (!/^\+256\d{9}$/.test(ugxPhoneNumber)) { > throw new Error("Invalid Ugandan phone number"); > } > // West African (XOF): Senegal (+221), Ivory Coast (+225), Benin (+229) > if (!/^\+(221\d{9}|225\d{10}|229\d{8,9})$/.test(xofPhoneNumber)) { > throw new Error("Invalid West African phone number"); > } > ``` > [accordion] Check account status > > Verify status before sending payments: > > ```javascript theme={null} > if (account.status !== "ACTIVE") { > throw new Error(`Account is ${account.status}, cannot process payment`); > } > ``` > [accordion] Secure account data > > Never expose full account numbers. Display only masked info: > > ```javascript theme={null} > function displaySafely(account) { > return { > id: account.id, > bankName: account.accountInfo.bankName, > lastFour: account.accountInfo.accountNumber.slice(-4), > status: account.status, > }; > } > ``` # Internal Accounts Source: https://agents-docs.lightspark.com/global-p2p/managing-accounts/internal-accounts Create and manage internal accounts Internal accounts are Lightspark managed accounts that hold funds within the Grid platform. They allow you to receive deposits and send payments to external bank accounts or other payment destinations. They are useful for holding funds on behalf of the platform or customers which will be used for instant, 24/7 quotes and transfers out of the system. Internal accounts are created for both: * **Platform-level accounts**: Hold pooled funds for your platform operations (rewards distribution, reconciliation, etc.) * **Customer accounts**: Hold individual customer funds for their transactions > [!NOTE] > > Internal accounts are automatically created when you onboard a customer, based > on your platform's currency configuration. Platform-level internal accounts > are created when you configure your platform with supported currencies. > [!WARNING] > > On unregulated platforms, a customer's internal accounts aren't provisioned > until you record their End User Terms acceptance in `agreementConsents`, and > customer-scoped transactions are rejected with `403 > END_USER_TERMS_NOT_ACCEPTED` until then. See > [Disclosures](/payouts-and-b2b/onboarding/disclosures). ## How internal accounts work Internal accounts act as an intermediary holding account in the payment flow: 1. **Deposit funds**: You or your customers deposit money into internal accounts using bank transfers (ACH, wire, PIX, etc.) or crypto transfers 2. **Hold balance**: Funds are held securely in the internal account until needed 3. **Send payments**: You initiate transfers from internal accounts to external destinations Each internal account: * Is denominated in a single currency (USD, EUR, etc.) * Has a unique balance that you can query at any time * Includes unique payment instructions for depositing funds * Supports multiple funding methods depending on the currency ## Retrieving internal accounts ### List customer internal accounts To retrieve all internal accounts for a specific customer, use the customer ID to filter the results: ```bash Request internal accounts for a customer theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [accordion] Response > > ```json theme={null} > { > "data": [ > { > "id": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "type": "INTERNAL_FIAT", > "status": "ACTIVE", > "balance": { > "amount": 50000, > "currency": { > "code": "USD", > "name": "United States Dollar", > "symbol": "$", > "decimals": 2 > } > }, > "totalBalance": { > "amount": 50000, > "currency": { > "code": "USD", > "name": "United States Dollar", > "symbol": "$", > "decimals": 2 > } > }, > "fundingPaymentInstructions": [ > { > "accountOrWalletInfo": { > "reference": "FUND-ABC123", > "accountType": "USD_ACCOUNT", > "accountNumber": "9876543210", > "routingNumber": "021000021", > "bankName": "JP Morgan Chase", > "bankAddress": "270 Park Avenue, New York, NY 10017" > } > }, > { > "accountOrWalletInfo": { > "accountType": "SOLANA_WALLET", > "assetType": "USDC", > "address": "4Nd1m6Qkq7RfKuE5vQ9qP9Tn6H94Ueqb4xXHzsAbd8Wg" > } > } > ], > "createdAt": "2025-10-03T12:00:00Z", > "updatedAt": "2025-10-03T14:30:00Z" > } > ], > "hasMore": false, > "totalCount": 1 > } > ``` ### Filter by currency You can filter internal accounts by currency to find accounts for specific denominations: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001¤cy=USD' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ### List platform internal accounts To retrieve platform-level internal accounts (not tied to individual customers), use the platform internal accounts endpoint: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/platform/internal-accounts' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [!NOTE] > > Platform internal accounts are useful for managing pooled funds, distributing > rewards, or handling platform-level operations. ## Understanding funding payment instructions Each internal account includes `fundingPaymentInstructions` that tell your customers how to deposit funds. The structure varies by payment rail and currency: > [accordion] US ACH or wire transfers > > For USD accounts, instructions include routing and account numbers: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "USD_ACCOUNT", > "reference": "FUND-ABC123", > "accountNumber": "9876543210", > "routingNumber": "021000021", > "bankName": "JP Morgan Chase", > "bankAddress": "270 Park Avenue, New York, NY 10017" > } > } > ``` > > > [!TIP] > > > > Each internal account has its own account number, and deposits are > > credited by account number. The payer doesn't need to include the > > `reference`. A deposit sent without it is still credited to this account. > > The account is held in the account owner's own legal name: the customer's > `fullName` for an individual, or `businessInfo.legalName` for a business. > A platform internal account is held in your platform's legal name. Show > that name to payers as the beneficiary name, and ask them to copy it > exactly on wires. > [accordion] EUR account funding (SEPA) > > A `EUR_ACCOUNT` returns an IBAN, and deposits reach it over SEPA: > > ```json theme={null} > { > "instructionsNotes": "Include reference in SEPA transfer description", > "accountOrWalletInfo": { > "accountType": "EUR_ACCOUNT", > "reference": "FUND-EUR789", > "iban": "DE89370400440532013000", > "swiftCode": "DEUTDEFF", > "bankName": "Deutsche Bank" > } > } > ``` > [accordion] Spark Stablecoin Funding > > For stablecoin accounts, using a Spark wallet as the funding source: > > ```json theme={null} > { > "invoice": "lnbc15u1p3xnhl2pp5jptserfk3zk4qy42tlucycrfwxhydvlemu9pqr93tuzlv9cc7g3sdqsvfhkcap3xyhx7un8cqzpgxqzjcsp5f8c52y2stc300gl6s4xswtjpc37hrnnr3c9wvtgjfuvqmpm35evq9qyyssqy4lgd8tj637qcjp05rdpxxykjenthxftej7a2zzmwrmrl70fyj9hvj0rewhzj7jfyuwkwcg9g2jpwtk3wkjtwnkdks84hsnu8xps5vsq4gj5hs", > "instructionsNotes": "Use the invoice when making Spark payment", > "accountOrWalletInfo": { > "accountType": "SPARK_WALLET", > "assetType": "USDB", > "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu" > } > } > ``` > [accordion] Solana Stablecoin Funding > > For Solana wallet accounts, using a Solana wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "SOLANA_WALLET", > "assetType": "USDC", > "address": "4Nd1m6Qkq7RfKuE5vQ9qP9Tn6H94Ueqb4xXHzsAbd8Wg" > } > } > ``` > [accordion] Ethereum Stablecoin Funding > > For Ethereum L1 wallet accounts (USDC or USDT), using an Ethereum wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "ETHEREUM_WALLET", > "assetType": "USDT", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` > [accordion] Tron Stablecoin Funding > > For Tron wallet accounts, using a Tron wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "TRON_WALLET", > "assetType": "USDT", > "address": "TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL" > } > } > ``` > [accordion] Plasma Stablecoin Funding > > For Plasma wallet accounts, using a Plasma wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "PLASMA_WALLET", > "assetType": "USDT", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` > [accordion] Arbitrum Stablecoin Funding > > For Arbitrum wallet accounts, using an Arbitrum wallet as the funding source (USDC or USDT): > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "ARBITRUM_WALLET", > "assetType": "USDC", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` > [accordion] Polygon Stablecoin Funding > > For Polygon wallet accounts, using a Polygon wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "POLYGON_WALLET", > "assetType": "USDC", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` > [accordion] Base Stablecoin Funding > > For Base wallet accounts, using a Base wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "BASE_WALLET", > "assetType": "USDC", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` ## Checking account balances The internal account balance reflects all deposits and withdrawals. The balance includes: * **amount**: The balance amount in the smallest currency unit (cents for USD, centavos for MXN/BRL, etc.) * **currency**: Full currency details including code, name, symbol, and decimal places ### Example balance check ```bash Fetch the balance of an internal account theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001¤cy=USD' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [accordion] Response > > ```json theme={null} > { > "data": [ > { > "id": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "type": "INTERNAL_FIAT", > "status": "ACTIVE", > "balance": { > "amount": 50000, > "currency": { > "code": "USD", > "name": "United States Dollar", > "symbol": "$", > "decimals": 2 > } > }, > "totalBalance": { > "amount": 50000, > "currency": { > "code": "USD", > "name": "United States Dollar", > "symbol": "$", > "decimals": 2 > } > } > } > ], > "hasMore": false, > "totalCount": 1 > } > ``` > [!TIP] > > Always check the `decimals` field in the currency object to correctly convert > between display amounts and API amounts. For example, USD has 2 decimals, so > an amount of 50000 represents \$500.00. ## Displaying funding instructions to customers When customers need to deposit funds themselves, display the funding payment instructions in your application: **1. Retrieve the internal account** Fetch the customer's internal account for their desired currency using the API. **2. Extract payment instructions** Parse the `fundingPaymentInstructions` array and select the appropriate instructions based on your customer's preferred payment method. ```javascript theme={null} const instructions = account.fundingPaymentInstructions[0]; const bankInfo = instructions.accountOrWalletInfo; ``` **3. Display instructions clearly** Show the payment details prominently in your UI: * Account holder name * Bank name and routing information (account/routing number, CLABE, PIX key, etc.) * Reference code (if provided) * Any additional notes from `instructionsNotes` > [!TIP] > > The unique banking details in each internal account automatically route > deposits to the correct destination. **4. Monitor for deposits** Set up webhook listeners to receive notifications when deposits are credited to the internal account. The account balance will update automatically. > [!NOTE] > > You'll receive `INTERNAL_ACCOUNT.BALANCE_UPDATED` webhook events when the internal account balance changes. ## Best practices > [accordion] Display complete funding instructions > > Ensure your customers have all the information needed to make deposits. Consider implementing: > > * Clear display of all banking details from `fundingPaymentInstructions` > * Copy-to-clipboard functionality for account numbers and reference codes > * Email/SMS confirmations with complete deposit instructions > [accordion] Monitor account balances proactively > > Set up monitoring to alert customers when their balance is low: > > ```javascript theme={null} > if (account.balance.amount < minimumThreshold) { > await notifyCustomer({ > type: 'LOW_BALANCE', > account: account.id, > instructions: account.fundingPaymentInstructions > }); > } > ``` > [accordion] Handle multiple currencies efficiently > > If your platform supports multiple currencies, organize internal accounts by currency in your UI: > > ```javascript theme={null} > const accountsByCurrency = accounts.data.reduce((acc, account) => { > const code = account.balance.currency.code; > acc[code] = account; > return acc; > }, {}); > > // Quick lookup: accountsByCurrency['USD'] > > ``` > [accordion] Cache account data appropriately > > Internal account details (especially funding instructions) rarely change, so you can cache them safely. However, always fetch fresh balance data before initiating transfers. # Configuring Customers Source: https://agents-docs.lightspark.com/global-p2p/onboarding/configuring-customers Creating and managing customers for global P2P payments This guide provides comprehensive information about creating customers in the Grid API, including customer types, onboarding models, registration, and management. ## Onboarding model There are two models for regulated and unregulated platforms. * Regulated platforms: Use your existing compliance processes. Create individual and business customers directly via `POST /customers`. The information you supply is used for beneficiary/counterparty compliance screening. * Unregulated platforms: Grid performs KYC (individuals) and KYB (businesses). Choose either the **hosted link flow** (redirect or embed Grid's provider for verification) or **direct API onboarding** — for `INDIVIDUAL` customers, submit personal information through `POST /customers`; for `BUSINESS` customers, also register beneficial owners via `POST /beneficial-owners`. Both paths produce the same `kycStatus` transitions and emit the same `CUSTOMER.KYC_APPROVED` / `CUSTOMER.KYC_REJECTED` / `CUSTOMER.KYC_PENDING` (and `CUSTOMER.KYB_*` equivalents) webhooks. While verification is pending, allow customers to finish account setup but block funding and money movement. ## Customer Types The Grid API supports both individual and business customers. While the API schema itself makes most Personally Identifiable Information (PII) optional at initial creation, specific fields may become mandatory based on the currencies the customer will transact with. Your platform’s configuration (retrieved via `GET /config`) includes a supportedCurrencies array. Each currency object within this array has a providerRequiredCustomerFields list. If a customer is intended to use a specific currency, any fields listed for that currency must be provided when creating or updating the customer. The only required field for all customers is `customerType` (`INDIVIDUAL` or `BUSINESS`). It's recommended to also pass a `platformCustomerId` to tie the customer to your internal identifier, but if you don't, one will be generated automatically. If using sending and receiving to just-in-time UMA addresses, you'll also need to specify the bank account information ## Creating Customers ### Individual customers In some cases, only the above fields are required at customer creation. Beyond those base requirements, additional fields commonly associated with individual customers include: * Full name * Date of birth (YYYY-MM-DD format) * Physical address (including country, state, city, postalCode) **Note:** Check the `providerRequiredCustomerFields` for each relevant currency in your platform's configuration to determine which of these fields are strictly mandatory at creation/update time for that customer to transact in those currencies. ### Business customers Beyond the base requirements, additional fields commonly associated with business customers include: * Business information: * Legal name (this is often required, check `providerRequiredCustomerFields`) * Registration number (optional, unless specified by `providerRequiredCustomerFields`) * Tax ID (optional, unless specified by `providerRequiredCustomerFields`) * Physical address (including country, state, city, postalCode) **Note:** Check the `providerRequiredCustomerFields` for each relevant currency in your platform's configuration to determine which of these fields are strictly mandatory at creation/update time for that customer to transact in those currencies. When creating or updating customers, the `customerType` field must be specified as either `INDIVIDUAL` or `BUSINESS`. > [!TIP] > > There can be multiple customers with the same platformCustomerId but different UMA addresses. This is useful if you want to track multiple UMA addresses and/or bank accounts for the same customer in your platform. ## What to Collect What you collect depends on the customer type: **KYC** for `INDIVIDUAL` customers, **KYB** for `BUSINESS` customers. Grid validates against these requirements when you call `POST /verifications` — anything missing comes back as a `RESOLVE_ERRORS` entry naming the exact field or document. > [!NOTE] > > Most of these fields are optional to the API schema at customer creation and only become mandatory at verification. Two things widen the list further: the `providerRequiredCustomerFields` for each currency in your platform's configuration (`GET /config`), and the KYC provider's per-jurisdiction rules. Treat the lists below as the baseline, not the ceiling. ### KYC — individual customers Personal information, submitted via `POST /customers` (or `PATCH /customers/{customerId}`): | Field | Notes | | - | - | | `fullName` | Must contain both a given name and a family name — a single-word name is rejected at submission | | `birthDate` | `YYYY-MM-DD` | | `nationality` | ISO 3166-1 alpha-2 | | `address` | `line1`, `postalCode`, and `country` are required; `line2`, `city`, and `state` as applicable | | `idType` + `identifier` | A tax ID: `SSN` (format `###-##-####`) or `ITIN`, or `NON_US_TAX_ID` with its non-US `countryOfIssuance`. US residents must provide an `SSN` or `ITIN` | | `email` / `phoneNumber` | Required in regions that verify a contact channel before verification begins (for example, the EU) | Documents, uploaded via `POST /documents` — identity documents also require `documentNumber` and `issuingAuthority`: * **Identity document** — a `PASSPORT` (single image), or a `DRIVERS_LICENSE` / `NATIONAL_ID` / `RESIDENCE_PERMIT` uploaded as separate `FRONT` and `BACK` images via the `side` field. * **Proof of address** — requested in some jurisdictions and for some currencies. Any of `UTILITY_BILL`, `ELECTRICITY_BILL`, `RENT_OR_LEASE_AGREEMENT`, `BANK_STATEMENT`, or `TAX_RETURN`, dated within the last 3 months. Some jurisdictions, flows, and volume tiers also require enhanced due diligence. These are optional top-level fields on the individual customer — `sourceOfFundsCategories`, `sourceOfWealthCategories`, `purposeOfAccount`, `expectedMonthlyTransactionCount`, `expectedMonthlyTransactionVolume`, `annualIncomeRange`, `netWorthRange`, and `pepStatus`. The provider decides which apply; you learn which ones through `MISSING_FIELD` errors on `POST /verifications` rather than up front. Each `*Categories` field that includes `OTHER` also requires its matching `*OtherDescription`. ### KYB — business customers When Grid runs KYB for a business customer, the following information and documents are collected before onboarding completes. Use this list to plan what to gather from the business — provide it via `POST /customers`, `POST /beneficial-owners`, and `POST /documents` (or through the hosted flow). #### Business identifying information * Entity full legal name * Doing Business As (DBA) name, if applicable * Physical address — the principal place of business, local office, or other physical location of the entity opening the account * Countries of operation * Identification number — U.S. taxpayer identification number, or, for a foreign business without one, alternative government-issued documentation certifying the existence of the business #### Ownership and control structure Collected for: * **One control person** — a single individual with significant responsibility to control, manage, or direct the legal entity, **and** * **All beneficial owners** — every individual who owns 25% or more of the legal entity, directly or indirectly. For every such individual, provide: * Full name * Date of birth * Address * Identification number, by residency: * **U.S. persons** — Social Security Number (SSN) or Individual Taxpayer Identification Number (ITIN) * **Non-U.S. persons** — one or more of: ITIN, passport number with country of issuance, alien identification card number, or another government-issued document evidencing nationality or residence and bearing a photograph or similar safeguard #### Required documents * Company formation and existence documents. For example: * Certificate of incorporation * Articles of association * Proof of ownership and control structure. For example: * Corporate organization and ownership chart * Shareholder agreements * Operating agreements * Register of members * Certification of controlling person and beneficial owners * Proof of address, dated within the last 3 months. For example: * Utility bill * Bank statement * Lease agreement * Official correspondence * Tax ID or equivalent identifying-number documents * For non-U.S. beneficial owners — passport plus one additional government-issued ID. For example: * National ID ## Regulated Platforms > [!TIP] > > **Regulated platforms** have lighter KYC requirements since they handle compliance verification internally. The KYC/KYB flow allows you to onboard customers through direct API calls. Regulated financial institutions can: * **Direct API Onboarding**: Create customers directly via API calls with minimal verification * **Internal KYC/KYB**: Handle identity verification through your own compliance systems * **Reduced Documentation**: Only provide essential customer information required by your payment counterparty or service provider. * **Faster Onboarding**: Streamlined process for known, verified customers ### Creating customers via direct API For regulated platforms, you can create customers directly through the API without requiring external KYC verification: To register a new customer in the system, use the `POST /customers` endpoint: ```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": "customer_12345", "customerType": "INDIVIDUAL", "fullName": "Jane Doe", "birthDate": "1992-03-25", "nationality": "US", "address": { "line1": "123 Pine Street", "city": "Seattle", "state": "WA", "postalCode": "98101", "country": "US" } }' ``` The examples below show a more comprehensive set of data. Not all fields are strictly required by the API for customer creation itself, but become necessary based on currency and UMA provider requirements if using UMA. > [tab] Individual customer > > ```json theme={null} > { > "platformCustomerId": "9f84e0c2a72c4fa", > "customerType": "INDIVIDUAL", > "fullName": "John Sender", > "birthDate": "1985-06-15", > "address": { > "line1": "Paseo de la Reforma 222", > "line2": "Piso 15", > "city": "Ciudad de México", > "state": "Ciudad de México", > "postalCode": "06600", > "country": "MX" > } > } > ``` > [tab] Business Customer > > ```json theme={null} > { > "platformCustomerId": "b87d2e4a9c13f5b", > "customerType": "BUSINESS", > "businessInfo": { > "legalName": "Acme Corporation", > "country": "US", > "registrationNumber": "789012345", > "taxId": "123-45-6789", > "incorporatedOn": "2018-03-14", > "businessType": "INFORMATION" > }, > "address": { > "line1": "456 Oak Avenue", > "line2": "Floor 12", > "city": "Seattle", > "state": "WA", > "postalCode": "98101", > "country": "US" > } > } > ``` ## Unregulated Platforms > [!NOTE] > > **Unregulated platforms** rely on Grid to run KYC for individuals and KYB for businesses. You can onboard customers either through the **hosted KYC/KYB link flow** below, or by **submitting customer data directly through the API**. Both paths cover KYC and KYB, produce the same status transitions — on `kycStatus` for individuals, `kybStatus` for businesses — and emit the same `CUSTOMER.KYC_*` / `CUSTOMER.KYB_*` webhooks. You can also mix them: supply what you already hold through the API, then let the hosted flow collect the rest. Either path works for unregulated platforms: * **Hosted flow**: Redirect customers to a Grid-hosted link (or embed the provider SDK). Covers individuals and businesses alike. Best when you want Grid to handle the entire collection UX. * **Direct API onboarding**: Collect customer information in your own UI and submit it via the API. For `INDIVIDUAL` customers (KYC), personal information goes through `POST /customers`. For `BUSINESS` customers (KYB), you also register beneficial owners via `POST /beneficial-owners`. Submit for review with `POST /verifications`. ### Hosted KYC and KYB Link Flow The hosted flow provides a secure, hosted interface where customers complete verification. It serves both customer types — there is no separate KYB link endpoint. `POST /customers/{customerId}/kyc-link` generates the link, and `customerType` selects which flow the provider runs: * **`INDIVIDUAL`** — identity verification (KYC), tracked on `kycStatus`. * **`BUSINESS`** — business verification (KYB), tracked on `kybStatus`. The applicant confirms the company details, uploads the formation, ownership, and proof-of-address documents, and declares the control person and every beneficial owner holding 25% or more. The flow is two steps: create the customer with the information you have, then generate the link. The status starts at `UNVERIFIED` and moves to `PENDING` once the customer completes the flow and review begins. #### 1. Create the customer > [tab] Individual (KYC) > > Supply at least `customerType` and any fields you already have. > > ```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 '{ > "customerType": "INDIVIDUAL", > "platformCustomerId": "9f84e0c2a72c4fa", > "region": "US", > "currencies": ["USD", "USDC"], > "email": "jane.doe@example.com", > "fullName": "Jane Doe" > }' > ``` > [tab] Business (KYB) > > `customerType: BUSINESS` requires these fields — the request fails without them: > > | Field | Notes | > | - | - | > | `businessInfo.legalName` | Full legal entity name | > | `businessInfo.country` | Country of incorporation, ISO 3166-1 alpha-2. Sets the applicant's jurisdiction and the tax-ID format validated against | > | `businessInfo.taxId` | Validated against `businessInfo.country` | > | `businessInfo.incorporatedOn` | `YYYY-MM-DD` | > > ```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 '{ > "customerType": "BUSINESS", > "platformCustomerId": "biz-acme-001", > "region": "US", > "currencies": ["USD", "USDC"], > "email": "finance@acme.com", > "businessInfo": { > "legalName": "Acme Corporation, Inc.", > "country": "US", > "taxId": "47-1234567", > "incorporatedOn": "2018-03-14", > "registrationNumber": "5523041", > "entityType": "LLC" > } > }' > ``` > > Everything else is optional to the schema, but three groups matter in practice: > > * **Contact channels** — `email` and/or `phoneNumber`, plus `businessInfo.primaryContactFirstName` and `primaryContactLastName`, are required in regions that verify a named representative before verification begins (for example the EU). > * **Currency-driven fields** — anything listed in `providerRequiredCustomerFields` for a currency the business will transact in. > * **Everything the review needs** — `address`, `registrationNumber`, `entityType`, `countriesOfOperation`, `businessType`, `purposeOfAccount`, `sourceOfFunds`, and the expected-activity fields aren't enforced at creation. Whatever you omit, the applicant is asked for in the flow. Persist the returned `id` (the Grid customer ID) — you'll need it for the next step. > [!TIP] > > Business information you supply is prefilled into the hosted flow, so send as much as you have — via `POST /customers` or a later `PATCH /customers/{customerId}` — **before** generating the link. Every field you pass is one the applicant doesn't retype, and one fewer chance for their answer to diverge from your record. #### 2. Generate the link ```bash theme={null} curl -X POST "https://api.lightspark.com/grid/2025-10-13/customers/Customer:019542f5-b3e7-1d02-0000-000000000001/kyc-link" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "redirectUri": "https://yourapp.com/onboarding-complete" }' ``` **Response:** ```json theme={null} { "kycUrl": "https://kyc.lightspark.com/onboard/abc123def456", "expiresAt": "2027-01-15T14:32:00Z", "provider": "SUMSUB", "token": "_act-sbx-jwt-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` > [!TIP] > > The response field is named `kycUrl` for both customer types; for a `BUSINESS` customer it opens the KYB flow. For providers that support direct SDK integration (currently SUMSUB), a `token` is also returned — pass it to the provider's web SDK to embed verification in your own UI instead of redirecting. Both update the customer's status identically. #### The flow end to end **1. Create the customer** Call `POST /customers` with `customerType` and any pre-fill fields you have. The returned `id` is the customer's Grid ID. Their status — `kycStatus` for an individual, `kybStatus` for a business — starts at `UNVERIFIED`, becomes `PENDING` while under review, and lands on `APPROVED` or `REJECTED` when the decision is made. (`HOLD` means the customer may be asked to supply more information.) **2. Verify contact channels (only where required)** Some regions (for example, the EU) require the customer's email and/or phone to be verified before verification can begin. For a business customer, these are the primary contact's details. When this applies, the customer carries a `contactVerification` object listing **only the channels that are required** — for example `{ "email": "PENDING", "phone": "PENDING" }`, or just `{ "email": "PENDING" }` if a provider requires email alone. When the object is absent, no contact verification is required and you can skip this step. For each channel that's present, drive a send-then-confirm pair (the provider delivers a one-time code by email/SMS): * `POST /customers/{customerId}/verify-email`, then `POST /customers/{customerId}/verify-email/confirm` with `{ "code": "..." }` * `POST /customers/{customerId}/verify-phone`, then `POST /customers/{customerId}/verify-phone/confirm` with `{ "code": "..." }` Re-POST the send endpoint to resend a code. Each channel moves `PENDING` → `VERIFIED`. (`verify-email`/`verify-phone` return `409` for a channel the provider doesn't require.) > [!NOTE] > > Generating the link before every present channel is `VERIFIED` returns `409`. **3. Generate the link** Call `POST /customers/{customerId}/kyc-link`. Each call returns a fresh single-use `kycUrl` and `expiresAt`; previously-issued links remain single-use but aren't invalidated. > [!NOTE] > > The `redirectUri` you pass is embedded in the generated `kycUrl` and is used to automatically return the customer to your application after they complete verification. **4. Send the customer through verification** Redirect the customer to `kycUrl`, or — if you want to embed the flow directly — initialize the provider's SDK with the returned `token`. A business applicant completes the company details, uploads the company documents, and declares the beneficial owners here. > [!WARNING] > > The hosted URL is single-use and expires at `expiresAt`. If a customer needs to retry, call the endpoint again to generate a new link. **5. Track the decision** Reaching your `redirectUri` only means the customer **finished the hosted flow** — not that they were approved. Wait for the final decision in one of two ways: * **Webhook (recommended):** Subscribe to `CUSTOMER.KYC_APPROVED` / `CUSTOMER.KYC_REJECTED` (and `CUSTOMER.KYB_APPROVED` / `CUSTOMER.KYB_REJECTED` for business customers) to be notified when the customer reaches a terminal status. `CUSTOMER.KYC_PENDING` (and the `KYB_PENDING` sibling) also fires when the customer is submitted for review — subscribe to it as well if you want to surface an "under review" state to the customer. * **Polling:** Call `GET /customers/{customerId}` and inspect `kycStatus` (individuals) or `kybStatus` (businesses). `GET /verifications?customerId=...` gives the finer-grained `verificationStatus` and `errors` array. That detail drives direct API onboarding; in the hosted flow the customer resolves it inside the flow, so integrate against `kycStatus` / `kybStatus`. **6. Handle completion** On `APPROVED`, the customer is ready to transact — proceed with account setup and unlock funding. On `REJECTED`, surface the appropriate next step (for example, regenerate the link or request manual review). While the status is `PENDING`, let the customer finish account setup but block funding and money movement. > [!WARNING] > > Testing the business flow in sandbox: by default the hosted flow collects the full packet, and a `businessInfo.registrationNumber` **not** ending in `001`, `002`, or `003` auto-approves the customer once that submission is complete. With **Skip verification paperwork** on, that suffix resolves before any packet validation, leaving the hosted flow nothing to verify — so it asks for no documents and no beneficial owners. Use a `003` suffix to exercise document and UBO collection. See [Sandbox testing](/api-reference/sandbox-testing). ### Direct API Onboarding Prefer to collect identity information in your own UI and submit it to Grid yourself? Use the API directly instead of redirecting to a hosted link. The customer's `kycStatus` transitions the same way and you receive the same `CUSTOMER.KYC_APPROVED` / `CUSTOMER.KYC_REJECTED` / `CUSTOMER.KYC_PENDING` (and `CUSTOMER.KYB_*` equivalents) webhooks. The shape of the flow depends on the customer type: * **KYC (`INDIVIDUAL` customers)** — supply the customer's personal information through the customer endpoint. No beneficial owners are involved. * **KYB (`BUSINESS` customers)** — create the business customer, then register its beneficial owners, directors, and officers individually. > [tab] KYC (individual) > > **1. Create the customer with personal information** > > Call `POST /customers` with `customerType: INDIVIDUAL` and the personal information collected from the customer (legal name, date of birth, address, nationality, etc.). The returned `id` is the customer's Grid ID; `kycStatus` starts at `UNVERIFIED` and moves to `PENDING` once you submit for verification. > > > [!NOTE] > > > > `fullName` must contain both a given name and a family name (for example, `"Jane Doe"`). The name is split into first/last for the verification provider, and a single-word name is rejected at submission. > > ```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 '{ > "customerType": "INDIVIDUAL", > "platformCustomerId": "9f84e0c2a72c4fa", > "fullName": "Jane Doe", > "birthDate": "1985-06-15", > "nationality": "US", > "email": "jane.doe@example.com", > "address": { > "line1": "123 Pine Street", > "city": "Seattle", > "state": "WA", > "postalCode": "98101", > "country": "US" > } > }' > ``` > > > [!NOTE] > > > > Where the customer's provider requires it (for example, EU customers), the customer carries a `contactVerification` object and their email and phone must be `VERIFIED` before you submit for verification. Drive each channel with the send-then-confirm pairs (`verify-email` / `verify-email/confirm` and `verify-phone` / `verify-phone/confirm`) described in the Hosted flow's "Verify email and phone" step. When `contactVerification` is absent, no contact verification is required. > > **2. Upload the identity document** > > Individual verification requires an identity document — a `PASSPORT` (single image), or a `DRIVERS_LICENSE` / `NATIONAL_ID` / `RESIDENCE_PERMIT` (uploaded as separate `FRONT` and `BACK` images via the `side` field). Some jurisdictions or currencies also request additional documents such as proof of address. Upload each one with `POST /documents` using `multipart/form-data`; identity documents also require `documentNumber` and `issuingAuthority`. Files must be `application/pdf`, `image/jpeg`, or `image/png`, and 10 MB or smaller. A photo of a document has to show all four corners in frame, in focus and in color, or the upload is rejected with `422 DOCUMENT_REJECTED`. > > ```bash theme={null} > curl -X POST "https://api.lightspark.com/grid/2025-10-13/documents" \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -F "documentHolder=Customer:019542f5-b3e7-1d02-0000-000000000001" \ > -F "documentType=PASSPORT" \ > -F "documentNumber=A12345678" \ > -F "issuingAuthority=U.S. Department of State" \ > -F "country=US" \ > -F "file=@./passport.jpg" > ``` > > **3. Submit for verification** > > Call `POST /verifications` to submit the customer for review. The response includes a `verificationStatus`. If anything is missing, `verificationStatus` is `RESOLVE_ERRORS` and the `errors` array describes exactly what to collect before retrying. > > ```bash theme={null} > curl -X POST "https://api.lightspark.com/grid/2025-10-13/verifications" \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -H "Content-Type: application/json" \ > -d '{ > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001" > }' > ``` > > **Submitted successfully:** > > ```json theme={null} > { > "id": "Verification:019542f5-b3e7-1d02-0000-000000000002", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "verificationStatus": "IN_PROGRESS", > "errors": [], > "createdAt": "2025-10-03T12:00:00Z" > } > ``` > > **Blocked by missing data:** > > ```json theme={null} > { > "id": "Verification:019542f5-b3e7-1d02-0000-000000000001", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "verificationStatus": "RESOLVE_ERRORS", > "errors": [ > { > "resourceId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "type": "MISSING_IDENTITY_DOCUMENT", > "acceptedDocumentTypes": ["PASSPORT", "DRIVERS_LICENSE", "NATIONAL_ID"], > "reason": "Identity document is required" > } > ], > "createdAt": "2025-10-03T12:00:00Z" > } > ``` > > **4. Track the decision** > > Track terminal `kycStatus` transitions via the `CUSTOMER.KYC_APPROVED` / `CUSTOMER.KYC_REJECTED` webhook (recommended) or by polling `GET /customers/{customerId}`. `CUSTOMER.KYC_PENDING` also fires when the customer is submitted for review — subscribe to it if you want to surface an "under review" state. On `APPROVED`, unlock funding and money movement. > [tab] KYB (business) > > When Grid runs KYB for a business customer, the following information and documents are collected before onboarding completes. Use this list to plan what to gather from the business — provide it via `POST /customers`, `POST /beneficial-owners`, and `POST /documents` (or through the hosted flow). > > #### Business identifying information > > * Entity full legal name > * Doing Business As (DBA) name, if applicable > * Physical address — the principal place of business, local office, or other physical location of the entity opening the account > * Countries of operation > * Identification number — U.S. taxpayer identification number, or, for a foreign business without one, alternative government-issued documentation certifying the existence of the business > > #### Ownership and control structure > > Collected for: > > * **One control person** — a single individual with significant responsibility to control, manage, or direct the legal entity, **and** > * **All beneficial owners** — every individual who owns 25% or more of the legal entity, directly or indirectly. > > For every such individual, provide: > > * Full name > * Date of birth > * Address > * Identification number, by residency: > * **U.S. persons** — Social Security Number (SSN) or Individual Taxpayer Identification Number (ITIN) > * **Non-U.S. persons** — one or more of: ITIN, passport number with country of issuance, alien identification card number, or another government-issued document evidencing nationality or residence and bearing a photograph or similar safeguard > > #### Required documents > > * Company formation and existence documents. For example: > * Certificate of incorporation > * Articles of association > * Proof of ownership and control structure. For example: > * Corporate organization and ownership chart > * Shareholder agreements > * Operating agreements > * Register of members > * Certification of controlling person and beneficial owners > * Proof of address, dated within the last 3 months. For example: > * Utility bill > * Bank statement > * Lease agreement > * Official correspondence > * Tax ID or equivalent identifying-number documents > * For non-U.S. beneficial owners — passport plus one additional government-issued ID. For example: > * National ID > > **1. Create the business customer** > > Call `POST /customers` with `customerType: BUSINESS` and the business information (legal name, country of incorporation, registration number, tax ID, registered address). > > ```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 '{ > "customerType": "BUSINESS", > "platformCustomerId": "b87d2e4a9c13f5b", > "businessInfo": { > "legalName": "Acme Corporation", > "country": "US", > "registrationNumber": "789012345", > "taxId": "123-45-6789", > "incorporatedOn": "2018-03-14", > "businessType": "INFORMATION" > }, > "address": { > "line1": "456 Oak Avenue", > "city": "Seattle", > "state": "WA", > "postalCode": "98101", > "country": "US" > } > }' > ``` > > **2. Register beneficial owners** > > Register each beneficial owner, director, or officer with `POST /beneficial-owners`. Each one is verified individually with their own personal information. > > ```bash theme={null} > curl -X POST "https://api.lightspark.com/grid/2025-10-13/beneficial-owners" \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -H "Content-Type: application/json" \ > -d '{ > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "roles": ["DIRECTOR"], > "ownershipPercentage": 40, > "personalInfo": { > "firstName": "Jane", > "lastName": "Doe", > "birthDate": "1985-06-15", > "nationality": "US", > "address": { > "line1": "123 Pine Street", > "city": "Seattle", > "state": "WA", > "postalCode": "98101", > "country": "US" > }, > "idType": "SSN", > "identifier": "123-45-6789" > } > }' > ``` > > **3. Upload supporting documents** > > Upload business documents (registration, articles of incorporation) and any documents required for individual beneficial owners with `POST /documents`. Reference the company by `customerId` and each owner by `beneficialOwnerId`. > > ```bash theme={null} > curl -X POST "https://api.lightspark.com/grid/2025-10-13/documents" \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -F "documentHolder=BeneficialOwner:019542f5-b3e7-1d02-0000-00000000abcd" \ > -F "documentType=PASSPORT" \ > -F "documentNumber=B98765432" \ > -F "issuingAuthority=U.S. Department of State" \ > -F "country=US" \ > -F "file=@./owner-passport.jpg" > ``` > > **4. Submit for verification** > > Call `POST /verifications` to submit the business for review. The response includes a `verificationStatus`. If beneficial owners, fields, or documents are missing, `verificationStatus` is `RESOLVE_ERRORS` and the `errors` array tells you exactly what to collect — note that `resourceId` may point at either the customer or a specific beneficial owner. > > ```bash theme={null} > curl -X POST "https://api.lightspark.com/grid/2025-10-13/verifications" \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -H "Content-Type: application/json" \ > -d '{ > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001" > }' > ``` > > **Blocked by missing data:** > > ```json theme={null} > { > "id": "Verification:019542f5-b3e7-1d02-0000-000000000001", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "verificationStatus": "RESOLVE_ERRORS", > "errors": [ > { > "resourceId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "type": "MISSING_FIELD", > "field": "customer.address.line1", > "reason": "Business address line 1 is required" > }, > { > "resourceId": "BeneficialOwner:019542f5-b3e7-1d02-0000-000000000002", > "type": "MISSING_FIELD", > "field": "personalInfo.birthDate", > "reason": "Date of birth is required for beneficial owners" > } > ], > "createdAt": "2025-10-03T12:00:00Z" > } > ``` > > **Submitted successfully:** > > ```json theme={null} > { > "id": "Verification:019542f5-b3e7-1d02-0000-000000000002", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "verificationStatus": "IN_PROGRESS", > "errors": [], > "createdAt": "2025-10-03T12:00:00Z" > } > ``` > > **5. Track the decision** > > Track terminal `kycStatus` transitions via the `CUSTOMER.KYB_APPROVED` / `CUSTOMER.KYB_REJECTED` webhook (recommended) or by polling `GET /customers/{customerId}`. `CUSTOMER.KYB_PENDING` also fires when the business is submitted for review — subscribe to it if you want to surface an "under review" state. On `APPROVED`, unlock funding and money movement. ## Customer Creation Process ### Creating a new individual customer (regulated institutions) To register a new customer directly, use the `POST /customers` endpoint (regulated institutions): ```bash theme={null} curl -sS -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": "9f84e0c2a72c4fa", "customerType": "INDIVIDUAL", "fullName": "Jane Doe", }' ``` The API allows creating a customer with minimal PII. However, to enable transactions for a customer in specific currencies, you must include any PII fields mandated by the `providerRequiredCustomerFields` for those currencies (found in your platform's configuration via `GET /config`). The examples below show a more comprehensive set of data. Not all fields are strictly required by the API for customer creation itself, but become necessary based on currency and provider requirements. Example request body for an individual customer with UMA instant payments enabled (ensure all `providerRequiredCustomerFields` for intended currencies are included): > [!NOTE] > > Typically bank account information is provided separately via internal and external account management. However, when using UMA for instant payments, since funding and withdrawals are instant, bank account information can be provided at time of customer creation. ```json theme={null} { "umaAddress": "$john.sender@mycompany.com", "platformCustomerId": "9f84e0c2a72c4fa", "customerType": "INDIVIDUAL", "fullName": "John Sender", "birthDate": "1985-06-15", "address": { "line1": "Paseo de la Reforma 222", "line2": "Piso 15", "city": "Ciudad de México", "state": "Ciudad de México", "postalCode": "06600", "country": "MX" } } ``` > [!NOTE] > > UMA addresses follow the format `$username@domain`. For your platform: > > 1. The `domain` part will be your configured UMA domain (set in platform configuration) > 2. The `username` part can be chosen by you or your customers, following these rules: > * Must start with a \$ symbol. This is to differentiate from email addresses and clearly identify an uma address. > * The `username` portion is limited to a-z0-9-\_.+ > * Addresses are case-insensitive, but by convention are written only with lowercase letters > * Like email addresses, the maximum number of characters for the `username` portion of the address is 64 characters (including the \$). > > The Grid API validates these requirements and will return an error if they are not met. ### Creating a new business customer (regulated institutions) ```bash theme={null} curl -sS -X POST "https://api.lightspark.com/grid/2025-10-13/customers" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "umaAddress": "$acme.corp@mycompany.com", "platformCustomerId": "b87d2e4a9c13f5b", "customerType": "BUSINESS", "businessInfo": { "legalName": "Acme Corporation", "country": "US", "registrationNumber": "789012345", "taxId": "123-45-6789", "incorporatedOn": "2018-03-14", "businessType": "INFORMATION" }, "address": { "line1": "456 Oak Avenue", "line2": "Floor 12", "city": "Seattle", "state": "WA", "postalCode": "98101", "country": "US" } }' ``` ### Onboarding customers (unregulated institutions) Unregulated institutions should initiate a hosted KYC/KYB flow in two steps: create the customer, then generate a hosted KYC link for that customer. While KYC is pending, allow account setup but block funding and money movement. 1. Create the customer with `POST /customers`, supplying any information you already have. Only `customerType` is required. ```bash theme={null} curl -sS -X POST "https://api.lightspark.com/grid/2025-10-13/customers" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerType": "INDIVIDUAL", "platformCustomerId": "9f84e0c2a72c4fa", "region": "US", "currencies": ["USD", "USDC"], "email": "jane.doe@example.com", "fullName": "Jane Doe" }' ``` 2. Generate a hosted KYC link for the new customer: ```bash theme={null} curl -sS -X POST "https://api.lightspark.com/grid/2025-10-13/customers/Customer:019542f5-b3e7-1d02-0000-000000000001/kyc-link" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "redirectUri": "https://app.example.com/onboarding/completed" }' ``` Response: ```json theme={null} { "kycUrl": "https://kyc.grid.example/onboard/abc123", "expiresAt": "2027-01-15T14:32:00Z", "provider": "SUMSUB", "token": "_act-sbx-jwt-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` 3. Redirect the customer to `kycUrl`, or pass the optional `token` to the provider's SDK to embed verification directly in your UI. 4. After the user is redirected back to your app, they can continue with account setup until KYC review is complete. 5. Track the decision via the `CUSTOMER.KYC_APPROVED` / `CUSTOMER.KYC_REJECTED` webhook (use the `CUSTOMER.KYB_*` siblings for business customers), or poll `GET /customers/{customerId}` and inspect `kycStatus`. On `APPROVED`, the customer is ready to transact and you can unlock funding. ## Handling KYC/KYB Webhooks After a customer completes the KYC/KYB verification process, you'll receive webhook notifications about their KYC status. These notifications are sent to your configured webhook endpoint. > [!NOTE] > > For regulated platforms, customers are created with `APPROVED` KYC status by default. **Webhook Payload (sent to your endpoint):** ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000020", "type": "CUSTOMER.KYC_APPROVED", "timestamp": "2025-07-21T17:32:28Z", "data": { "id": "Customer:019542f5-b3e7-1d02-0000-000000000001", "platformCustomerId": "9f84e0c2a72c4fa", "customerType": "INDIVIDUAL", "umaAddress": "$john.doe@uma.domain.com", "kycStatus": "APPROVED", "fullName": "John Michael Doe", "birthDate": "1990-01-15", "nationality": "US", "address": { "line1": "123 Main Street", "line2": "Apt 4B", "city": "Seattle", "state": "WA", "postalCode": "98101", "country": "US" }, "createdAt": "2025-07-21T17:32:28Z", "updatedAt": "2025-07-21T17:32:28Z", "isDeleted": false } } ``` **Webhook Headers:** * `Content-Type: application/json` * `X-Grid-Signature: {"v": "1", "s": "base64_signature..."}` **id** `string` *required* Unique identifier for this webhook delivery. Use this for idempotency to prevent processing duplicate webhooks. **type** `string` *required* Status-specific event type. KYC/KYB webhooks use `CUSTOMER.*` types: * `CUSTOMER.KYC_APPROVED`: Individual customer verification completed successfully * `CUSTOMER.KYC_REJECTED`: Individual customer verification was rejected * `CUSTOMER.KYC_PENDING`: Individual customer is awaiting review * `CUSTOMER.KYB_APPROVED` / `CUSTOMER.KYB_REJECTED` / `CUSTOMER.KYB_PENDING`: business equivalents (only fires for `customerType: BUSINESS`) **data** `object` *required* The full customer resource object, same as the corresponding `GET /customers/{id}` endpoint would return. Includes all customer fields such as `id`, `kycStatus`, `fullName`, `birthDate`, `nationality`, `address`, etc. > [!NOTE] > > `CUSTOMER.KYC_PENDING` (or `CUSTOMER.KYB_PENDING`) fires when a customer enters `kycStatus: PENDING` (for example, after they are submitted for review). Final outcomes use `CUSTOMER.KYC_APPROVED` / `CUSTOMER.KYC_REJECTED` (and the `CUSTOMER.KYB_*` siblings for business customers). > [accordion] Webhook Implementation Example > > ```javascript theme={null} > // Example webhook handler for KYC status updates. > // CUSTOMER.KYC_APPROVED and CUSTOMER.KYC_REJECTED are terminal decisions; > // CUSTOMER.KYC_PENDING is an intermediate signal that the customer has been > // submitted for review (useful for surfacing "we're still reviewing" UI). > app.post('/webhooks/kyc-status', async (req, res) => { > const { type, data } = req.body; > > switch (type) { > case 'CUSTOMER.KYC_APPROVED': > // Activate customer account > await activateCustomer(data.id); > await sendWelcomeEmail(data.id); > break; > > case 'CUSTOMER.KYC_REJECTED': > // Notify support and customer > await notifySupport(data.id, 'KYC_REJECTED'); > await sendRejectionEmail(data.id); > break; > > case 'CUSTOMER.KYC_PENDING': > // Intermediate: customer submitted for review, no decision yet > await markCustomerUnderReview(data.id); > break; > > case 'CUSTOMER.KYB_APPROVED': > // Business customer decision: activate > await activateCustomer(data.id); > await sendWelcomeEmail(data.id); > break; > > case 'CUSTOMER.KYB_REJECTED': > // Business customer decision: notify > await notifySupport(data.id, 'KYB_REJECTED'); > await sendRejectionEmail(data.id); > break; > > case 'CUSTOMER.KYB_PENDING': > // Intermediate: business customer submitted for review > await markCustomerUnderReview(data.id); > break; > > default: > // Log unexpected types > console.log(`Unexpected webhook type ${type} for customer ${data.id}`); > } > > res.status(200).send('OK'); > }); > ``` ### Handling Verification Issues The `CUSTOMER.KYC_*` and `CUSTOMER.KYB_*` events above tell you the decision. The `VERIFICATION.*` events tell you **why a verification is stuck and what to send to unstick it** — they carry the full `Verification` resource, including the `errors` array naming each unmet requirement. | Event | Meaning | What to do | | - | - | - | | `VERIFICATION.RESOLVE_ERRORS` | Blocked on missing, invalid, or rejected data | Resolve every entry in `errors`, then re-submit | | `VERIFICATION.PENDING_MANUAL_REVIEW` | A reviewer is assessing the case | Nothing — wait for the terminal event | | `VERIFICATION.IN_PROGRESS` | Submitted; provider checks are running | Nothing — wait | | `VERIFICATION.APPROVED` | Verification passed | Unlock funding and money movement | | `VERIFICATION.REJECTED` | Verification failed | Surface the outcome; see the non-recoverable errors below | > [!NOTE] > > These fire for both individuals and businesses — the customer type is determined by the customer referenced in `data.customerId`. They are most useful for direct API onboarding, where your integration is responsible for collecting what's missing. In the hosted flow the applicant resolves most issues inside the flow itself, so integrate against `kycStatus` / `kybStatus` there. **Example payload:** ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000031", "type": "VERIFICATION.RESOLVE_ERRORS", "timestamp": "2025-08-15T14:32:00Z", "data": { "id": "Verification:019542f5-b3e7-1d02-0000-000000000011", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "verificationStatus": "RESOLVE_ERRORS", "errors": [ { "resourceId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "type": "MISSING_PROOF_OF_ADDRESS_DOCUMENT", "acceptedDocumentTypes": ["UTILITY_BILL", "BANK_STATEMENT"], "reason": "Proof of address document is required" } ], "createdAt": "2025-08-15T14:00:00Z" } } ``` ### Reading an error **resourceId** `string` *required* The resource to fix — a `Customer`, a `BeneficialOwner`, or a `Document`. On a business verification this is what tells you *whose* data is incomplete, since one verification covers the company and every owner. **type** `string` *required* The error category. Drives what you do next — see the remediation table below. **field** `string` Dot-notation path to the offending field (for example, `customer.address.line1`). Present for `MISSING_FIELD` and `INVALID_FIELD`. **acceptedDocumentTypes** `array` The document types that would satisfy the requirement — upload any one of them. Present on the document-related error types. **reason** `string` *required* Human-readable description of the issue. Safe to log; write your own customer-facing copy rather than surfacing this text directly. ### Resolving each error type | Error types | How to resolve | | - | - | | `MISSING_FIELD`, `INVALID_FIELD` | `PATCH` the resource named by `resourceId` (`/customers/{id}` or `/beneficial-owners/{id}`), setting the path given in `field` | | `MISSING_LEGAL_PRESENCE_DOCUMENT`, `MISSING_CONTROL_STRUCTURE_DOCUMENT`, `MISSING_OWNERSHIP_STRUCTURE_DOCUMENT`, `MISSING_PROOF_OF_ADDRESS_DOCUMENT`, `MISSING_IDENTITY_DOCUMENT`, `MISSING_GOOD_STANDING_DOCUMENT` | `POST /documents` with any type listed in `acceptedDocumentTypes` | | `INVALID_DOCUMENT`, `EXPIRED_DOCUMENT`, `POOR_QUALITY_DOCUMENT`, `UNREADABLE_DOCUMENT`, `INCOMPLETE_DOCUMENT`, `WRONG_DOCUMENT_TYPE`, `DOCUMENT_VERIFICATION_FAILED` | Collect a replacement — current, legible, complete, and of an accepted type — and upload it with `POST /documents` | | `MISSING_BENEFICIAL_OWNER`, `MISSING_CONTROL_PERSON` | `POST /beneficial-owners` for the missing individual. Every owner at 25% or more, plus one control person, must be registered | | `SUSPECTED_FRAUD_DOCUMENT`, `APPLICANT_SANCTIONED`, `APPLICANT_FRAUD`, `APPLICANT_CRIMINAL_RECORD`, `APPLICANT_REJECTED` | Not resolvable by re-submitting. Route these to your compliance process | > [!WARNING] > > The `APPLICANT_*` types and `SUSPECTED_FRAUD_DOCUMENT` are compliance outcomes, not data problems. Re-submitting the same customer will not clear them, and retry loops on these are counterproductive — handle them as a terminal outcome for that customer. ### Re-submitting Once every recoverable error is resolved, call `POST /verifications` again with the same `customerId`. The response carries the updated `verificationStatus` — `RESOLVE_ERRORS` again if anything is still outstanding, so you can iterate without waiting for another webhook. To re-read the state at any point, call `GET /verifications?customerId={customerId}`. > [!NOTE] > > Once a customer reaches `APPROVED` or `REJECTED`, further `POST /verifications` calls return `400`. > [accordion] Webhook Implementation Example > > ```javascript theme={null} > // Handler for VERIFICATION.* events. RESOLVE_ERRORS is the one that needs work: > // every entry in data.errors names a resource, a category, and often the exact > // field or the document types that would satisfy it. > const NON_RECOVERABLE = new Set([ > 'SUSPECTED_FRAUD_DOCUMENT', > 'APPLICANT_SANCTIONED', > 'APPLICANT_FRAUD', > 'APPLICANT_CRIMINAL_RECORD', > 'APPLICANT_REJECTED', > ]); > > app.post('/webhooks/verification-status', async (req, res) => { > const { id, type, data } = req.body; > > // The webhook id is unique per delivery — use it to drop duplicates. > if (await alreadyProcessed(id)) { > return res.status(200).send('OK'); > } > > switch (type) { > case 'VERIFICATION.RESOLVE_ERRORS': { > const blocking = data.errors.filter((e) => NON_RECOVERABLE.has(e.type)); > if (blocking.length > 0) { > await escalateToCompliance(data.customerId, blocking); > break; > } > // Ask the customer for exactly what's outstanding, then re-submit > // with POST /verifications once it's all supplied. > await requestOutstandingItems(data.customerId, data.errors); > break; > } > > case 'VERIFICATION.PENDING_MANUAL_REVIEW': > case 'VERIFICATION.IN_PROGRESS': > await markCustomerUnderReview(data.customerId); > break; > > case 'VERIFICATION.APPROVED': > await activateCustomer(data.customerId); > break; > > case 'VERIFICATION.REJECTED': > await notifySupport(data.customerId, 'VERIFICATION_REJECTED'); > break; > > default: > console.log(`Unexpected webhook type ${type} for ${data.customerId}`); > } > > await recordProcessed(id); > res.status(200).send('OK'); > }); > ``` ## Customer management ### Retrieving customer information You can retrieve customer information using either the Grid-assigned customer ID or your platform's customer ID: ```bash theme={null} curl -sS -X GET "https://api.lightspark.com/grid/2025-10-13/customers/{customerId}" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` or list customers with a filter: ```bash theme={null} curl -sS -G "https://api.lightspark.com/grid/2025-10-13/customers" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ --data-urlencode "umaAddress={umaAddress}" \ --data-urlencode "platformCustomerId={platformCustomerId}" \ --data-urlencode "customerType={customerType}" \ --data-urlencode "createdAfter={createdAfter}" \ --data-urlencode "createdBefore={createdBefore}" \ --data-urlencode "cursor={cursor}" \ --data-urlencode "limit={limit}" ``` Note that this example shows all available filters. You can use any combination of them. ## Data validation The Grid API performs validation on all customer data. Common validation rules include: * All required fields must be present based on customer type * Date of birth must be in YYYY-MM-DD format and represent a valid date * Names must not contain special characters * Bank account information must follow country-specific formats * Addresses must include all required fields including country code If validation fails, the API will return a 400 Bad Request response with detailed error information. ## Bulk customer import operations For scenarios where you need to add many customers to the system at once, the API provides a CSV file upload endpoint. ### CSV file upload For large-scale customer imports, you can upload a CSV file containing customer information: ```bash theme={null} curl -sS -X POST "https://api.lightspark.com/grid/2025-10-13/customers/bulk/csv" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -F "file=@customers.csv" ``` The CSV file should follow a specific format with required and optional columns based on customer type. Here's an example: ```csv theme={null} umaAddress,platformCustomerId,customerType,fullName,birthDate,addressLine1,city,state,postalCode,country,businessLegalName $john.doe@uma.domain.com,cust_user123,INDIVIDUAL,John Doe,1990-01-15,123 Main St,San Francisco,CA,94105,US, $acme@uma.domain.com,cust_biz456,BUSINESS,,,400 Commerce Way,Austin,TX,78701,US,Acme Corp ``` > [!TIP] > > CSV Upload Best Practices > > 1. Use a spreadsheet application to prepare your CSV file > 2. Validate data before upload (e.g., date formats, required fields) > 3. Include a header row with column names > 4. Use UTF-8 encoding for special characters > 5. Keep file size under 100MB for optimal processing You can track the job status through: 1. Webhook notifications (if configured) 2. Status endpoint: ```bash theme={null} curl -sS -X GET "https://api.lightspark.com/grid/2025-10-13/customers/bulk/jobs/{jobId}" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Example job status response: ```json theme={null} { "id": "Job:019542f5-b3e7-1d02-0000-000000000006", "status": "PROCESSING", "progress": { "total": 5000, "processed": 2500, "successful": 2450, "failed": 50 }, "errors": [ { "correlationId": "cust_biz456", "code": "INVALID_FIELD", "message": "Invalid bank account number" } ] } ``` # Invitations Source: https://agents-docs.lightspark.com/global-p2p/onboarding/invitations The Grid API provides an invitation system that allows platform customers to invite others to create accounts and receive payments. This guide explains how to use the invitation system effectively. > [!NOTE] > > See the full [UMA invitations guide](https://docs.lightspark.com/uma-invitations/introduction) for details outside of the context of the Grid API. ## Overview The invitation system enables: * Platform users to create invitations with optional payments for others * Direct prospects to sign up for your services by claiming an invitation to receive payment * Tracking of invitation status (pending, claimed, expired) * Webhook notifications when invitations are claimed ## Setup Before starting your implementation, we recommend configuring UMAs and invitations in the dashboard. You will need to provide: * A name and a logo to be displayed in the invitation page. * A list of countries you operate UMA in. * An onboarding URL for people who want to create an account with you (this URL must support invitation codes). Example: `http://myplatform.com/uma?code=INVITATION_CODE` * A webhook URL to get notified when your invitations get claimed. ## Creating Invitations To create an invitation, make a POST request to `/invitations` with the inviter's UMA address: ```bash theme={null} curl -X POST "https://api.lightspark.com/grid/2025-10-13/invitations" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "inviterUma": "$inviter@uma.domain", "expiresAt": "2024-12-31T23:59:59Z" }' ``` The response will include a unique invitation code that can be shared with the invitee: ```json theme={null} { "code": "019542f5", "createdAt": "2023-09-01T14:30:00Z", "inviterUma": "$inviter@uma.domain", "url": "https://uma.me/i/019542f5", "status": "PENDING" } ``` The `url` field is the URL where the invitee can claim the invitation. For example, for the response above, you might want to generate a share message for the user with text like: "Get an UMA address so that I can send you some money! `https://uma.me/i/019542f5`". The inviter can then share this URL with the invitee. When the invitee clicks the URL, they will be presented with a list of UMA providers available in their region. The invitee can select one of the providers and onboard to create their UMA address. ## Pay-by-Link Invitations The Grid API supports a "pay-by-link" feature that allows users to create invitations that include a payment amount. This is useful for scenarios where you want to send money to someone who doesn't yet have an UMA address or where the sender doesn't know the receiver's UMA address. They can simply share a link via email, SMS, WhatsApp, or other channels to send money. To create a pay-by-link invitation, include the `amountToSend` field when creating the invitation: ```bash theme={null} curl -X POST "https://api.lightspark.com/grid/2025-10-13/invitations" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "inviterUma": "$inviter@uma.domain", "amountToSend": 5000, "expiresAt": "2024-12-31T23:59:59Z" }' ``` Assuming the user's currency is USD, this example creates an invitation that will send \$50 USD to the invitee when they claim it. The response will include the payment amount in the invitation details: ```json theme={null} { "code": "019542f5", "createdAt": "2023-09-01T14:30:00Z", "inviterUma": "$inviter@uma.domain", "url": "https://uma.me/i/019542f5", "status": "PENDING", "amountToSend": { "amount": 5000, "currency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 } } } ``` When the invitee claims the invitation, your platform will receive an `INVITATION.CLAIMED` webhook. At this point, you should: 1. Check the `amountToSend` field in the webhook payload 2. Create a quote for the payment amount (sender-locked) 3. Execute the payment to the invitee's UMA address Note that the actual sending of the payment must be done by your platform after receiving the webhook. If your platform either does not send the payment or the payment fails, the invitee will not receive the amount. The `amountToSend` field is primarily used for display purposes on the claiming side of the invitation. These payments can only be sender-locked, meaning that the sender will not know ahead of time how much the receiver will receive in their local currency. The exchange rate will be determined at the time the payment is executed. If you'd like, you can also send a push notification to your sending user when you receive the `INVITATION.CLAIMED` webhook and have them approve the payment interactively instead. ### Best practices 1. Always set an expiration time for invitations with a payment amount to avoid huge swings in expected exchange rates or leaked links. 2. Allow the inviter to cancel the invitation if they want to avoid sending a payment to the wrong person if the link is leaked. 3. Notify the inviter when the invitation is claimed so that they can see the amount received by the invitee. ## Claiming Invitations Once onboarding (or login from an invite link) is complete, the invitee's new VASP (which may or may not be the same as the inviter's Grid API platform) will need to claim the invitation by making a POST request to `/invitations/{invitationCode}/claim` including the invitee's newly-created UMA address: ```bash theme={null} curl -X POST "https://api.lightspark.com/grid/2025-10-13/invitations/019542f5/claim" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "inviteeUma": "$invitee@uma.domain" }' ``` A successful claim will: 1. Associate the invitee's UMA address with the invitation 2. Change the invitation status from `PENDING` to `CLAIMED` 3. Trigger an `INVITATION.CLAIMED` webhook to notify the inviter's platform of the claim ## Cancelling Invitations To cancel a pending invitation, make a POST request to `/invitations/{invitationCode}/cancel`: ```bash theme={null} curl -X POST "https://api.lightspark.com/grid/2025-10-13/invitations/019542f5/cancel" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` A successful cancellation will: 1. Change the invitation status from `PENDING` to `CANCELLED` 2. Make the invitation URL show as cancelled when accessed 3. Prevent the invitation from being claimed Only the inviter or platform can cancel an invitation, and only pending invitations can be cancelled. Attempting to cancel an invitation that is already claimed, expired, or cancelled will result in an error. Example error response for an already claimed invitation: ```json theme={null} { "code": "invitation_already_claimed", "message": "This invitation has already been claimed and cannot be cancelled", "details": { "status": "CLAIMED", "inviteeUma": "$invitee@uma.domain" } } ``` ## Invitation Status An invitation can be in one of four states: * `PENDING`: The invitation has been created but not yet claimed * `CLAIMED`: The invitation has been successfully claimed by an invitee * `EXPIRED`: The invitation has expired and can no longer be claimed * `CANCELLED`: The invitation has been cancelled by the inviter or platform You can check the status of an invitation at any time by making a GET request to `/invitations/{invitationCode}`. ## Webhook Integration When an invitation is claimed, the Grid API will send an `INVITATION.CLAIMED` webhook to your configured webhook endpoint. This allows you to: * Track invitation usage and conversion rates * Apply referral bonuses or rewards to the inviter * Update your UI to reflect the claimed status Example webhook payload: ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000008", "type": "INVITATION.CLAIMED", "timestamp": "2023-09-01T15:45:00Z", "data": { "code": "019542f5", "createdAt": "2023-09-01T14:30:00Z", "claimedAt": "2023-09-01T15:45:00Z", "inviterUma": "$inviter@uma.domain", "inviteeUma": "$invitee@uma.domain", "url": "https://uma.me/i/019542f5", "status": "CLAIMED" } } ``` See the [Webhooks Guide](../platform-tools/webhooks) for more information about webhook security and implementation. ## Best Practices 1. **Expiration Times**: Consider setting appropriate expiration times for invitations based on your use case 2. **User Experience**: Provide clear feedback to users about invitation status and next steps 3. **Monitoring**: Track invitation metrics to understand user acquisition patterns ## Error Handling Common error scenarios to handle: * Invalid invitation code * Expired invitation * Already claimed invitation * Rate limit exceeded * Missing required fields Example error response: ```json theme={null} { "code": "invitation_expired", "message": "This invitation has expired and cannot be claimed", "details": { "expirationTime": "2023-08-31T23:59:59Z" } } ``` # Postman Collection Source: https://agents-docs.lightspark.com/global-p2p/platform-tools/postman-collection > [!NOTE] > > Use our hosted Postman collection to explore endpoints and send test requests quickly. ![](/images/icons/IconPostman.svg) Open Postman Collection Launch the collection in Postman. [Open](https://www.postman.com/lightspark-dev/lightspark-grid/collection/adligoh/grid-api?action=share\&source=copy-link\&creator=49207478) # Sandbox Testing Source: https://agents-docs.lightspark.com/global-p2p/platform-tools/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 | | `$fail.payout.compliance.usd@sandbox.uma.money` | Payout fails with `failureReason: COMPLIANCE_REJECTED` | | `$fail.payout.accountinvalid.usd@sandbox.uma.money` | Payout fails with `failureReason: ACCOUNT_INVALID` | | `$fail.payout.cannotreceive.usd@sandbox.uma.money` | Payout fails with `failureReason: ACCOUNT_CANNOT_RECEIVE` | | `$fail.payout.limitexceeded.usd@sandbox.uma.money` | Payout fails with `failureReason: LIMIT_EXCEEDED` | ## 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 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 stamp the exact returned `payloadToSign` string 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 [Encrypt the OTP code](/global-accounts/client-keys#encrypt-the-otp-code-email_otp-only) 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. **1. Create a WebAuthn credential** Generate your own WebAuthn registration challenge and call `navigator.credentials.create()`. **2. Register the passkey** Register the passkey with `POST /auth/credentials`, passing the challenge and attestation returned by the browser. **3. Request a challenge** Reauthenticate with `POST /auth/credentials/{id}/challenge`, passing the compressed P-256 `clientPublicKey` (lowercase hex) whose private key becomes the session signing key. **4. Run the browser assertion** Pass the returned `challenge` into `navigator.credentials.get()` using the returned `credentialId` in `allowCredentials`. **5. Verify the assertion** Verify with `POST /auth/credentials/{id}/verify`, passing the browser assertion and echoing `Request-Id` from the challenge response. The sandbox validates the registered credential ID, WebAuthn challenge, origin/RP binding, user-presence bit, assertion signature, and signature counter. A successful verify response returns the session, just like production. The private key matching the `clientPublicKey` is the session signing key. ```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": "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" }' # 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": "..." } }' ``` ### 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 the lowercase hex SHA-256 digest of `clientPublicKey`, hashed as the exact string sent in the verify request. That string is the compressed public key in lowercase hex: 66 characters starting with `02` or `03`. ```bash theme={null} export PUBLIC_KEY="02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" OIDC_TOKEN=$(node - <<'NODE' const crypto = require("crypto"); const publicKey = process.env.PUBLIC_KEY || "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31"; 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. ### Wallet signature header For `PASSKEY` and `OAUTH` credentials and session refresh, the private key matching the `clientPublicKey` you supplied is the session signing key. For `EMAIL_OTP`, the TEK private key you generated for the encrypted OTP flow is the session signing key. 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. ```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. ## 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! # UMA Test Wallet Source: https://agents-docs.lightspark.com/global-p2p/platform-tools/uma-test-wallet Test UMA payment flows with a real counterparty Grid provides an UMA Test Wallet to help you test UMA payment flows with a real counterparty. The UMA Test Wallet is an external tool that demonstrates UMA payment flows end to end and gives you a realistic counterparty for development and QA. It helps you understand flows through hands-on interaction, explore recommended UX patterns, and develop against a live UMA FI. ![](/images/IconGlobus.svg) Launch Test Wallet Open the hosted test wallet to try UMA flows in your browser. [Open](https://test.uma.me) ![](/images/github.svg) GitHub Repository Browse the code, file issues, and contribute improvements. [Open](https://github.com/uma-universal-money-address/uma-test-wallet) ### What UMA Test Wallet can do * **Experience UMA flows**: Send and receive cross-currency UMA payments * **Preview UX best practices**: See recommended entry points, confirmations, and error handling. * **Develop and test**: Use the wallet as a counterparty FI when building UMA integrations > [!NOTE] > > For background on UMA itself, see the UMA Standard: [UMA Standard—Introduction](https://docs.uma.me/uma-standard/introduction). # Webhooks Source: https://agents-docs.lightspark.com/global-p2p/platform-tools/webhooks Security best practices for webhook verification All webhooks sent by the Grid API include a signature in the `X-Grid-Signature` header, which allows you to verify the authenticity of the webhook. This is critical for security, as it ensures that only legitimate webhooks from Grid are processed by your system. ## Signature Verification Process 1. **Obtain your Grid public key** * This is available in your [dashboard](https://app.lightspark.com) under Developers webhook settings. * The key is in PEM format and can be used with standard cryptographic libraries 2. **Verify incoming webhooks** * Extract the signature from the `X-Grid-Signature` header * Decode the base64 signature * Create a SHA-256 hash of the entire request body * Verify the signature using the Grid webhook public key and the hash * Only process the webhook if the signature verification succeeds ## Verification Examples ### Node.js Example ```javascript theme={null} const crypto = require('crypto'); const express = require('express'); const app = express(); // Your Grid public key provided during integration const GRID_WEBHOOK_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE... -----END PUBLIC KEY-----`; app.post('/webhooks/uma', (req, res) => { const signatureHeader = req.header('X-Grid-Signature'); if (!signatureHeader) { return res.status(401).json({ error: 'Signature missing' }); } try { let signature: Buffer; try { // Parse the signature as JSON. It's in the format {"v": "1", "s": "base64_signature"} const signatureObj = JSON.parse(signatureHeader); if (signatureObj.v && signatureObj.s) { // The signature is in the 's' field signature = Buffer.from(signatureObj.s, "base64"); } else { throw new Error("Invalid JSON signature format"); } } catch { // If JSON parsing fails, treat as direct base64 signature = Buffer.from(signatureHeader, "base64"); } // Create verifier with the public key and correct algorithm const verifier = crypto.createVerify("SHA256"); const payload = await request.text(); verifier.update(payload); verifier.end(); // Verify the signature using the webhook public key const isValid = verifier.verify( { key: GRID_WEBHOOK_PUBLIC_KEY, format: "pem", type: "spki", }, signature, ); if (!isValid) { return res.status(401).json({ error: 'Invalid signature' }); } // Webhook is verified, process it based on type const webhookData = req.body; if (webhookData.type.startsWith('INCOMING_PAYMENT.')) { // Process incoming payment webhook // ... } else if (webhookData.type.startsWith('OUTGOING_PAYMENT.')) { // Process outgoing payment webhook // ... } // Acknowledge receipt of the webhook return res.status(200).json({ received: true }); } catch (error) { console.error('Signature verification error:', error); return res.status(401).json({ error: 'Signature verification failed' }); } }); app.listen(3000, () => { console.log('Webhook server listening on port 3000'); }); ``` ### Python Example ```python theme={null} from cryptography.hazmat.primitives import serialization, hashes from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature from flask import Flask, request, jsonify import base64 app = Flask(__name__) # Your Grid public key provided during integration GRID_PUBLIC_KEY = """-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE... -----END PUBLIC KEY-----""" # Load the public key public_key = serialization.load_pem_public_key( GRID_PUBLIC_KEY.encode('utf-8') ) @app.route('/webhooks/uma', methods=['POST']) def handle_webhook(): # Get signature from header signature = request.headers.get('X-Grid-Signature') if not signature: return jsonify({'error': 'Signature missing'}), 401 try: # Get the raw request body request_body = request.get_data() # Create a SHA-256 hash of the request body hash_obj = hashes.Hash(hashes.SHA256()) hash_obj.update(request_body) digest = hash_obj.finalize() # Decode the base64 signature signature_bytes = base64.b64decode(signature) # Verify the signature try: public_key.verify( signature_bytes, request_body, ec.ECDSA(hashes.SHA256()) ) except Exception as e: return jsonify({'error': 'Invalid signature'}), 401 # Webhook is verified, process it based on type webhook_data = request.json if webhook_data['type'].startswith('INCOMING_PAYMENT.'): # Process incoming payment webhook # ... pass elif webhook_data['type'].startswith('OUTGOING_PAYMENT.'): # Process outgoing payment webhook # ... pass # Acknowledge receipt of the webhook return jsonify({'received': True}), 200 except Exception as e: print(f'Signature verification error: {e}') return jsonify({'error': 'Signature verification failed'}), 401 if __name__ == '__main__': app.run(port=3000) ``` ## Testing To test your webhook implementation, you can trigger a test webhook from the Grid dashboard. This will send a test webhook to the endpoint you provided during the integration process. The test webhook will also be sent automatically when you update your platform configuration with a new webhook URL. An example of the test webhook payload is shown below: ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000007", "type": "TEST", "timestamp": "2025-08-15T14:32:00Z", "data": {} } ``` You should verify the signature of the webhook using the Grid public key and the process outlined in the [Signature Verification Process](#signature-verification-process) section and then reply with a 200 OK response to acknowledge receipt of the webhook. ## Security Considerations * **Always verify signatures**: Never process webhooks without verifying their signatures. * **Use HTTPS**: Ensure your webhook endpoint uses HTTPS to prevent man-in-the-middle attacks. * **Implement idempotency**: Use the `id` field to prevent processing duplicate webhooks. * **Timeout handling**: Implement proper timeout handling and respond to webhooks promptly. ## Retry Policy The Grid API will retry webhooks with the following policy based on the webhook type: | Webhook Type | Retry Policy | Notes | | - | - | - | | `TEST` | No retries | Used for testing webhook configuration | | `OUTGOING_PAYMENT.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `INCOMING_PAYMENT.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on: 409 (duplicate webhook) or PENDING status since it is served as an approval mechanism in-flow | | `BULK_UPLOAD.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `WALLET_OPERATION.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `INVITATION.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CUSTOMER.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `ACCOUNT.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CARD.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CARD_TRANSACTION.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CARD_AUTHORIZATION.REQUEST` | No retries | Real-time decision request, sent only while `cardConfigs.realTimeAuthorization` is set | # Quickstart Source: https://agents-docs.lightspark.com/global-p2p/quickstart Send your first cross-border payment ![Global P2P quickstart hero](https://mintcdn.com/agents-docs/qL8kqlSYbvexR8cg/images/heroes/hero-quickstart-globalp2p-light.jpg?fit=max\&auto=format\&n=qL8kqlSYbvexR8cg\&q=85\&s=593f8110502a3b41dac0583f3fc67bd9) With Global P2P you can send and receive payments in any supported fiat or crypto currency. This quickstart guides you through a regulated FI sending an individual customer payment from the US to a bank account in Mexico using USDC just-in-time funding. For examples of funding with real-time fiat rails, see the [Sending Payments](/global-p2p/sending-receiving-payments/sending-payments) guide. ## Understanding Entity Mapping for Remittances In this guide, the entities map as follows: | Entity Type | Who They Are | In This Example | | - | - | - | | **Platform** | Your remittance service | Your money transfer app | | **Customer** | Both sender and recipient | Alice (sender in US), Carlos (recipient in Mexico) | | **External Account** | Bank accounts for funding/receiving | Alice's US bank (funding), Carlos's Mexican bank (receiving funds) | **Flow**: Alice (customer) funds a transfer from her external account → to Carlos (also a customer) → who receives funds in his external bank account. Alternatively, Alice could send directly to Carlos's UMA address if he has one. ## Get API credentials Create Sandbox API credentials in the [dashboard](https://app.lightspark.com), then set environment variables for local use. ```bash theme={null} export GRID_BASE_URL="https://api.lightspark.com/grid/2025-10-13" export GRID_CLIENT_ID="YOUR_SANDBOX_CLIENT_ID" export GRID_CLIENT_SECRET="YOUR_SANDBOX_CLIENT_SECRET" ``` > [!TIP] > > Use Basic Auth in cURL with `-u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET"`. ## Create a customer Register a customer who will send the payment. You can provide your own UMA handle or let the system generate one. ```bash theme={null} curl -sS -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": "cust_7b3c5a89d2f1e0", "customerType": "INDIVIDUAL", "umaAddress": "$alice@yourapp.example", "fullName": "Alice Smith", "address": { "line1": "123 Pine Street", "city": "Seattle", "state": "WA", "postalCode": "98101", "country": "US" } }' ``` Response: ```json theme={null} { "id": "Customer:019542f5-b3e7-1d02-0000-000000000001", "customerType": "INDIVIDUAL", "umaAddress": "$alice@yourapp.example", "platformCustomerId": "cust_7b3c5a89d2f1e0" } ``` ## Create an external receiving bank account (CLABE in MX) Add a beneficiary account in Mexico using their CLABE. We attach it to the same customer for this example. ```bash theme={null} curl -sS -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": "MXN", "platformAccountId": "mx_beneficiary_001", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "accountInfo": { "accountType": "MXN_ACCOUNT", "clabeNumber": "123456789012345678", "bankName": "BBVA Mexico", "beneficiary": { "beneficiaryType": "INDIVIDUAL", "fullName": "Carlos Pérez", "birthDate": "1985-03-15", "nationality": "MX", "address": { "line1": "Av. Reforma 123", "city": "Ciudad de México", "state": "CDMX", "postalCode": "06600", "country": "MX" } } } }' ``` Response: ```json theme={null} { "id": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "currency": "MXN", "status": "ACTIVE", "platformAccountId": "mx_beneficiary_001", "accountInfo": { "accountType": "MXN_ACCOUNT", "clabeNumber": "123456789012345678", "bankName": "BBVA Mexico", "beneficiary": { "beneficiaryType": "INDIVIDUAL", "fullName": "Carlos Pérez", "birthDate": "1985-03-15", "nationality": "MX", "address": { "line1": "Av. Reforma 123", "city": "Ciudad de México", "state": "CDMX", "postalCode": "06600", "country": "MX" } } } } ``` ## Create a quote (just‑in‑time funding) Quote a transfer of 100 USD from the customer to the MXN CLABE account. The quote returns the exchange rate, fees, and `paymentInstructions` to fund in real time. ```bash theme={null} curl -sS -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:019542f5-b3e7-1d02-0000-000000000001", "currency": "USDC" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 10000, "description": "Remittance to MX beneficiary" }' ``` Response: ```json theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000006", "status": "PENDING", "source": { "sourceType": "REALTIME_FUNDING", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "currency": "USDC" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "sendingCurrency": { "code": "USDC", "name": "USD Coin", "symbol": "USDC", "decimals": 2 }, "receivingCurrency": { "code": "MXN", "name": "Mexican Peso", "symbol": "$", "decimals": 2 }, "totalSendingAmount": 10000, "totalReceivingAmount": 168500, "exchangeRate": 16.85, "feesIncluded": 50, "platformFeesIncluded": 0, "paymentInstructions": [ { "accountOrWalletInfo": { "accountType": "SOLANA_WALLET", "address": "0x1234567890123456789012345678901234567890", "assetType": "USDC" } }, { "accountOrWalletInfo": { "accountType": "BASE_WALLET", "address": "0x1234567890123456789012345678901234567890", "assetType": "USDC" } } ] } ``` ## Fund the quote (Sandbox simulation) In production, you would trigger a payment on one of the supported blockchains to the provided address. In Sandbox, you can mock funding using the simulate send endpoint. ```bash theme={null} curl -sS -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": "USDC" }' ``` Response: ```json theme={null} { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000005", "status": "PROCESSING", "type": "OUTGOING", "direction": "DEBIT", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "platformCustomerId": "alice_user_id", "source": { "sourceType": "REALTIME_FUNDING", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "currency": "USDC" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "sentAmount": { "amount": 10000, "currency": { "code": "USDC", "decimals": 2 } }, "receivedAmount": { "amount": 168500, "currency": { "code": "MXN", "decimals": 2 } }, "quoteId": "Quote:019542f5-b3e7-1d02-0000-000000000006" } ``` ## Handle the outgoing payment webhook Implement a webhook endpoint to receive status updates as the payment moves from pending to completed (or failed). Verify the `X-Grid-Signature` header using the public key provided during onboarding. Webhook event: ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000007", "type": "OUTGOING_PAYMENT.COMPLETED", "timestamp": "2025-01-15T14:32:00Z", "data": { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000005", "status": "COMPLETED", "type": "OUTGOING", "direction": "DEBIT", "sentAmount": { "amount": 10000, "currency": { "code": "USDC", "decimals": 2 } }, "receivedAmount": { "amount": 168500, "currency": { "code": "MXN", "decimals": 2 } }, "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "quoteId": "Quote:019542f5-b3e7-1d02-0000-000000000006" } } ``` # Depositing Funds Source: https://agents-docs.lightspark.com/global-p2p/sending-receiving-payments/depositing-funds Depositing funds into internal accounts Grid provides two options to fund an account: * Prefund * Just-in-time funding With prefunding, you'll deposit funds into internal accounts via Wire, PIX, hosted payment page, or crypto transfers. You can then use the balances as the source of funds for quotes and transfers. With just-in-time funding, you'll receive payment instructions as part of the quote. Once funds arrive, the payment to the receiver is automatically initiated. > [!NOTE] > > Just-in-time funding supports instant payment rails only (for example: RTP, > PIX, SEPA Instant). ## Prerequisites * You have created a customer (for customer-scoped internal accounts) * You have `GRID_CLIENT_ID` and `GRID_CLIENT_SECRET` > [!TIP] > > Export your credentials for use with cURL: > > ```bash theme={null} > export GRID_CLIENT_ID="your_client_id" > export GRID_CLIENT_SECRET="your_client_secret" > ``` ## Prefunding an account via push payments (Wire, SEPA, PIX, etc.) When customers need to deposit funds themselves, display the funding payment instructions in your application: **1. Retrieve the internal account** Fetch the customer's internal account for their desired currency using the API. ```bash cURL (Customer accounts) theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ```bash cURL (Platform internal accounts) theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/platform/internal-accounts' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **2. Extract payment instructions** Parse the `fundingPaymentInstructions` array and select the appropriate instructions based on your customer's preferred payment method. ```javascript theme={null} const instructions = account.fundingPaymentInstructions[0]; const bankInfo = instructions.accountOrWalletInfo; ``` **3. Display instructions clearly** Show the payment details prominently in your UI and enable copy / paste: * Account holder name * Bank name and routing information (account/routing number, CLABE, PIX key, etc.) * Payment URL (for COP deposits, redirect the customer to the `paymentUrl` returned in the payment instructions) * Reference code (if provided) * Any additional notes from `instructionsNotes` **Step 4** Customer initiates a push payment from their bank or wallet to the account/address specified. **5. Monitor for deposits** Set up webhook listeners to receive updates for the deposit transaction and account balance updates. The account balance will update automatically. > [!NOTE] > > You'll receive `INTERNAL_ACCOUNT.BALANCE_UPDATED` webhook events when the internal account balance changes. ## Just-in-time funding (payment instructions from a quote) With just-in-time funding, you request a quote and receive payment instructions (for example, a bank account or instant rail details). When your customer confirms the transaction, you trigger payment from your app. More details of just-in-time funding can be found in the Sending Payments guides. # Error Handling Source: https://agents-docs.lightspark.com/global-p2p/sending-receiving-payments/error-handling Handle payment failures, API errors, and transaction issues in Global P2P Learn how to handle errors when working with payments and transactions in Grid. Proper error handling ensures a smooth user experience and helps you quickly identify and resolve issues. ## HTTP status codes Grid uses standard HTTP status codes to indicate the success or failure of requests: | Status Code | Meaning | When It Occurs | | - | - | - | | `200 OK` | Success | Request completed successfully | | `201 Created` | Resource created | New customer, account, or credential created | | `202 Accepted` | Accepted for processing | Async operation initiated (e.g., bulk CSV upload) | | `400 Bad Request` | Invalid input | Missing required fields or invalid parameters | | `401 Unauthorized` | Authentication failed | Invalid or missing API credentials | | `403 Forbidden` | Permission denied | Insufficient permissions or customer not ready | | `404 Not Found` | Resource not found | Customer, transaction, or quote doesn't exist | | `408 Request Timeout` | Request timed out | The request body could not be read in time; retry | | `409 Conflict` | Resource conflict | Quote already executed, external account already exists, bank account still being validated | | `412 Precondition Failed` | UMA version mismatch | Counterparty doesn't support required UMA version | | `429 Too Many Requests` | Rate/volume limited | Rate limit hit or daily volume limit exceeded | | `422 Unprocessable Entity` | Missing info | Additional counterparty information required | | `424 Failed Dependency` | Counterparty issue | Problem with external UMA provider | | `500 Internal Server Error` | Server error | Unexpected server issue ([contact our team](https://www.lightspark.com/contact)) | | `501 Not Implemented` | Not implemented | Feature not yet supported | | `502 Bad Gateway` | Counterparty error | Counterparty returned an error or an invalid response; retry | | `504 Gateway Timeout` | Counterparty timeout | Counterparty could not be reached or timed out; retry | ## API error responses All error responses include a structured format: ```json theme={null} { "code": "INVALID_AMOUNT", "reason": "Amount must be greater than 0", "details": { "field": "amount", "value": -100 } } ``` ### Common error codes > [accordion] INVALID\_INPUT > > **Cause:** Missing required fields or invalid data format > > **Solution:** Check request parameters match API specification > > ```javascript theme={null} > // Error > { > "code": "INVALID_INPUT", > "reason": "Invalid account ID format" > } > > // Fix: Ensure proper ID format > const accountId = "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965"; > ``` > [accordion] QUOTE\_EXPIRED > > **Cause:** Attempting to execute an expired quote > > **Solution:** Create a new quote before executing > > ```javascript theme={null} > async function executeQuoteWithRetry(quoteId) { > try { > return await executeQuote(quoteId); > } catch (error) { > if (error.code === "QUOTE_EXPIRED") { > // Create new quote and execute > const newQuote = await createQuote(originalQuoteParams); > return await executeQuote(newQuote.id); > } > throw error; > } > } > ``` > [accordion] INSUFFICIENT\_BALANCE > > **Cause:** Internal account doesn't have enough funds > > **Solution:** Check balance before initiating transfer > > ```javascript theme={null} > async function safeSendPayment(accountId, amount) { > const account = await getInternalAccount(accountId); > > if (account.balance.amount < amount) { > throw new Error( > `Insufficient balance. Available: ${account.balance.amount}, Required: ${amount}` > ); > } > > return await createQuote({ accountId, amount }); > } > ``` > [accordion] BANK\_ACCOUNT\_VALIDATION\_PENDING > > **Cause:** Grid is still validating the US bank account on this request > > **Solution:** Wait and retry. Grid validates a newly added ACH account by sending a micro-entry to the bank and waiting out the return window, which takes a few banking days. The account has not been rejected, so do not ask the customer to re-enter their details. > > ```javascript theme={null} > async function createQuoteWhenAccountIsReady({ accountId, amount }) { > try { > return await createQuote({ accountId, amount }); > } catch (error) { > if (error.code === "BANK_ACCOUNT_VALIDATION_PENDING") { > // Surface the wait to the customer; the account is fine. > return { status: "AWAITING_ACCOUNT_VALIDATION" }; > } > throw error; > } > } > ``` > [accordion] INVALID\_BANK\_ACCOUNT > > **Cause:** Bank account details are invalid or incomplete > > **Solution:** Validate account details before submission > > ```javascript theme={null} > function validateUSAccount(account) { > if (!account.accountNumber || !account.routingNumber) { > throw new Error("Account and routing numbers required"); > } > > if (account.routingNumber.length !== 9) { > throw new Error("Routing number must be 9 digits"); > } > > return true; > } > ``` > [accordion] TRANSACTION\_SIZE\_LIMIT\_EXCEEDED > > **Cause:** The requested amount exceeds the maximum single-transaction limit for the trade corridor or withdrawal currency > > **Solution:** Reduce the transaction amount or split into multiple smaller transactions > > ```javascript theme={null} > async function handleLargeTransfer(amount, maxPerTransaction) { > if (amount <= maxPerTransaction) { > return await createQuote({ amount }); > } > > // Split into multiple transactions > const batches = []; > let remaining = amount; > > while (remaining > 0) { > const batchAmount = Math.min(remaining, maxPerTransaction); > batches.push(batchAmount); > remaining -= batchAmount; > } > > return Promise.all(batches.map((amt) => createQuote({ amount: amt }))); > } > ``` > [accordion] DAILY\_VOLUME\_LIMIT\_EXCEEDED > > **Cause:** The requested amount would exceed the daily volume limit for the trade corridor or withdrawal currency (HTTP 429) > > **Solution:** Wait until the next day or reduce the transaction amount > > ```javascript theme={null} > async function createQuoteWithLimitCheck(params) { > try { > return await createQuote(params); > } catch (error) { > if (error.code === "DAILY_VOLUME_LIMIT_EXCEEDED") { > // Notify user about daily limit > return { > error: "DAILY_VOLUME_LIMIT_EXCEEDED", > message: "Daily transfer limit reached. Try again tomorrow.", > retryAfter: getNextDayMidnight(), > }; > } > throw error; > } > } > > function getNextDayMidnight() { > const tomorrow = new Date(); > tomorrow.setDate(tomorrow.getDate() + 1); > tomorrow.setHours(0, 0, 0, 0); > return tomorrow.toISOString(); > } > ``` ## Transaction failure reasons When a transaction fails, the `failureReason` field provides specific details: ### Outgoing payment failures ```json theme={null} { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000030", "status": "FAILED", "type": "OUTGOING", "direction": "DEBIT", "failureReason": "QUOTE_EXECUTION_FAILED" } ``` **Common outgoing failure reasons:** * `QUOTE_EXPIRED` - Quote expired before execution * `QUOTE_RATE_UNAVAILABLE` - Quoted exchange rate was refused at execution; nothing was exchanged * `QUOTE_EXECUTION_FAILED` - Error executing the quote; a debited amount is refunded automatically * `FUNDING_AMOUNT_MISMATCH` - Funding amount doesn't match expected amount * `PAYOUT_RETURNED` - Receiving bank returned or reversed the payout * `LIMIT_EXCEEDED` - Payout exceeds a partner limit * `ACCOUNT_CANNOT_RECEIVE` - Recipient account can't accept the payment * `ACCOUNT_INVALID` - Recipient account details are wrong or not found * `COMPLIANCE_REJECTED` - Payout partner rejected on compliance grounds ### Incoming payment failures ```json theme={null} { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000005", "status": "FAILED", "type": "INCOMING", "direction": "CREDIT", "failureReason": "PAYMENT_APPROVAL_TIMED_OUT" } ``` **Common incoming failure reasons:** * `PAYMENT_APPROVAL_TIMED_OUT` - Webhook approval not received within 5 seconds * `PAYMENT_APPROVAL_WEBHOOK_ERROR` - Webhook returned an error * `OFFRAMP_FAILED` - Failed to convert and send funds to destination * `QUOTE_EXPIRED` - Quote expired during processing ## Handling failures ### Monitor transaction status ```javascript theme={null} async function monitorTransaction(transactionId) { const maxAttempts = 30; // 5 minutes with 10-second intervals let attempts = 0; while (attempts < maxAttempts) { const transaction = await getTransaction(transactionId); if (transaction.status === "COMPLETED") { return { success: true, transaction }; } if (transaction.status === "FAILED") { return { success: false, transaction, failureReason: transaction.failureReason, }; } // Still processing await new Promise((resolve) => setTimeout(resolve, 10000)); attempts++; } throw new Error("Transaction monitoring timed out"); } ``` ### Retry logic for transient errors ```javascript theme={null} async function createQuoteWithRetry(params, maxRetries = 3) { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await createQuote(params); } catch (error) { // Rate limiting (429 with RATE_LIMITED) is retryable with backoff // Volume limits (DAILY_VOLUME_LIMIT_EXCEEDED) are NOT retryable const isRateLimited = error.status === 429 && error.code === "RATE_LIMITED"; // 424 and 5xx are transient, except 501 Not Implemented const isRetryable = error.status === 424 || (error.status >= 500 && error.status !== 501) || isRateLimited; if (!isRetryable || attempt === maxRetries) { throw error; } // Use Retry-After header if present, otherwise exponential backoff const retryAfter = error.headers?.["retry-after"]; const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : Math.pow(2, attempt) * 1000; await new Promise((resolve) => setTimeout(resolve, delay)); } } } ``` ### Handle webhook failures ```javascript theme={null} app.post("/webhooks/grid", async (req, res) => { try { await processWebhook(req.body); res.status(200).json({ received: true }); } catch (error) { console.error("Webhook processing error:", error); // For pending payments, default to async processing if (req.body.transaction?.status === "PENDING") { // Queue for retry await queueWebhookForRetry(req.body); return res.status(202).json({ message: "Queued for processing" }); } // For other webhooks, acknowledge receipt res.status(200).json({ received: true }); } }); ``` ## Error recovery strategies > [tab] Quote Expiration > > Automatically create a new quote when one expires: > > ```javascript theme={null} > async function executeQuoteSafe(quoteId, originalParams) { > try { > return await fetch( > `https://api.lightspark.com/grid/2025-10-13/quotes/${quoteId}/execute`, > { > method: "POST", > headers: { Authorization: `Basic ${credentials}` }, > } > ); > } catch (error) { > if (error.status === 409 && error.code === "QUOTE_EXPIRED") { > // Create new quote with same parameters > const newQuote = await createQuote(originalParams); > > // Execute immediately > return await fetch( > `https://api.lightspark.com/grid/2025-10-13/quotes/${newQuote.id}/execute`, > { > method: "POST", > headers: { Authorization: `Basic ${credentials}` }, > } > ); > } > > throw error; > } > } > ``` > [tab] Insufficient Balance > > Notify users and suggest funding: > > ```javascript theme={null} > async function handleInsufficientBalance(customerId, requiredAmount) { > const accounts = await getInternalAccounts(customerId); > const account = accounts.data[0]; > > const shortfall = requiredAmount - account.balance.amount; > > // Notify customer > await sendNotification(customerId, { > type: "INSUFFICIENT_BALANCE", > message: `You need ${formatAmount( > shortfall > )} more to complete this payment`, > action: { > label: "Add Funds", > url: "/deposit", > }, > }); > > // Return funding instructions > return { > error: "INSUFFICIENT_BALANCE", > currentBalance: account.balance.amount, > requiredAmount, > shortfall, > fundingInstructions: account.fundingPaymentInstructions, > }; > } > ``` > [tab] Network Errors > > Implement retry with exponential backoff: > > ```javascript theme={null} > async function fetchWithRetry(url, options, maxRetries = 3) { > for (let i = 0; i < maxRetries; i++) { > try { > const response = await fetch(url, options); > > if (!response.ok) { > const error = await response.json(); > throw error; > } > > return await response.json(); > } catch (error) { > const isLastAttempt = i === maxRetries - 1; > const isNetworkError = > error.code === "ECONNRESET" || > error.code === "ETIMEDOUT" || > error.status === 500; > > if (isLastAttempt || !isNetworkError) { > throw error; > } > > // Wait before retry (exponential backoff) > const delay = Math.pow(2, i) * 1000; > await new Promise((resolve) => setTimeout(resolve, delay)); > } > } > } > ``` ## User-friendly error messages Convert technical errors to user-friendly messages: ```javascript theme={null} function getUserFriendlyMessage(error) { const errorMessages = { QUOTE_EXPIRED: "Exchange rate expired. Please try again.", QUOTE_RATE_UNAVAILABLE: "Exchange rate is no longer available. Please try again.", INSUFFICIENT_BALANCE: "You don't have enough funds for this payment.", INVALID_BANK_ACCOUNT: "Bank account details are invalid. Please check and try again.", BANK_ACCOUNT_VALIDATION_PENDING: "We're still verifying this bank account. This usually takes a few business days.", PAYMENT_APPROVAL_TIMED_OUT: "Payment approval timed out. Please try again.", AMOUNT_OUT_OF_RANGE: "Payment amount is too high or too low.", INVALID_CURRENCY: "This currency is not supported.", WEBHOOK_ENDPOINT_NOT_SET: "Payment receiving is not configured. Contact support.", TRANSACTION_SIZE_LIMIT_EXCEEDED: "This amount exceeds the maximum single transaction limit. Try a smaller amount.", DAILY_VOLUME_LIMIT_EXCEEDED: "Daily transfer limit reached. Please try again tomorrow.", }; return ( errorMessages[error.code] || error.message || "An unexpected error occurred. Please try again or contact support." ); } // Usage try { await createQuote(params); } catch (error) { const userMessage = getUserFriendlyMessage(error); showErrorToUser(userMessage); } ``` ## Logging and monitoring Implement comprehensive error logging: ```javascript theme={null} class PaymentErrorLogger { static async logError(error, context) { const errorLog = { timestamp: new Date().toISOString(), errorCode: error.code, errorMessage: error.message, httpStatus: error.status, context: { customerId: context.customerId, transactionId: context.transactionId, operation: context.operation, }, stackTrace: error.stack, }; // Log to your monitoring service await logToMonitoring(errorLog); // Alert on critical errors if (error.status >= 500 || error.code === "WEBHOOK_DELIVERY_ERROR") { await sendAlert({ severity: "high", message: `Payment error: ${error.code}`, details: errorLog, }); } return errorLog; } } // Usage try { await executeQuote(quoteId); } catch (error) { await PaymentErrorLogger.logError(error, { customerId: "Customer:123", operation: "executeQuote", }); throw error; } ``` ## Best practices > [accordion] Always validate before API calls > > Validate data on your side before making API requests to catch errors early: > > ```javascript theme={null} > function validateTransferRequest(request) { > const errors = []; > > if (!request.source?.accountId) { > errors.push("Source account ID is required"); > } > > if (!request.destination?.accountId) { > errors.push("Destination account ID is required"); > } > > if (!request.amount || request.amount <= 0) { > errors.push("Amount must be greater than 0"); > } > > if (errors.length > 0) { > throw new ValidationError(errors.join(", ")); > } > > return true; > } > ``` > [accordion] Implement idempotency > > Store transaction IDs to prevent duplicate submissions on retry: > > ```javascript theme={null} > const processedTransactions = new Set(); > > async function createTransferIdempotent(params) { > const idempotencyKey = generateKey(params); > > if (processedTransactions.has(idempotencyKey)) { > throw new Error("Transaction already processed"); > } > > try { > const result = await createQuote(params); > processedTransactions.add(idempotencyKey); > return result; > } catch (error) { > // Don't mark as processed on error > throw error; > } > } > ``` > [accordion] Set appropriate timeouts > > Configure timeouts for long-running operations: > > ```javascript theme={null} > async function executeWithTimeout(promise, timeoutMs = 30000) { > const timeout = new Promise((_, reject) => > setTimeout(() => reject(new Error("Operation timed out")), timeoutMs) > ); > > return Promise.race([promise, timeout]); > } > > // Usage > try { > const result = await executeWithTimeout( > executeQuote(quoteId), > 30000 // 30 seconds > ); > } catch (error) { > if (error.message === "Operation timed out") { > // Handle timeout specifically > console.log("Quote execution timed out, checking status..."); > const transaction = await checkTransactionStatus(quoteId); > } > } > ``` ## Next steps ![](/images/icons/paper-plane-top-right.svg) Send Payments Learn how to send payments from internal accounts [Open](/payouts-and-b2b/payment-flow/send-payment) ![](/images/icons/file-text.svg) List Transactions Query and filter payment history [Open](/payouts-and-b2b/payment-flow/list-transactions) ![](/images/icons/receipt-check.svg) Reconciliation Match payments with your internal systems [Open](/payouts-and-b2b/payment-flow/reconciliation) # Receiving Payments Source: https://agents-docs.lightspark.com/global-p2p/sending-receiving-payments/receiving-payments Receiving payments from UMA addresses This guide explains how to enable your customers to receive UMA payments. When an external sender initiates a payment to your customer's UMA address, the Grid API requests the counterparty fields you defined in your platform configuration. You can review the counterparty and transaction details for risk before approving the payment. > [!NOTE] > > Before you begin, make sure you: > > * Configure UMA, supported currencies, and required counterparty fields in [Platform Configuration](/global-p2p/getting-started/platform-configuration) > * Create customers and capture any provider-required user fields in [Creating Customers](/global-p2p/onboarding/configuring-customers) > * Set up and verify webhooks in [Webhooks](/global-p2p/platform-tools/webhooks) ## Receive webhook for initiated payment When someone initiates a payment to one of your users' UMA addresses, you'll receive a webhook call with a pending transaction: ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000007", "type": "INCOMING_PAYMENT.PENDING", "timestamp": "2023-08-15T14:32:00Z", "data": { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000005", "status": "PENDING", "type": "INCOMING", "direction": "CREDIT", "source": { "sourceType": "UMA_ADDRESS", "umaAddress": "$mary.sender@thelessgoodbank.com" }, "receivedAmount": { "amount": 50000, "currency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 } }, "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "platformCustomerId": "9f84e0c2a72c4fa", "description": "Payment for services", "counterpartyInformation": { "FULL_NAME": "Mary Sender", "BIRTH_DATE": "1985-06-15" }, "reconciliationInstructions": { "reference": "REF-123456789" }, "requestedReceiverCustomerInfoFields": [ { "name": "COUNTRY_OF_RESIDENCE", "mandatory": true }, { "name": "FULL_NAME", "mandatory": true }, { "name": "NATIONALITY", "mandatory": false } ] } } ``` The `counterpartyInformation` object contains PII about the sender, provided by their FI, based on your configured `requiredCounterpartyFields`. If present, `requestedReceiverCustomerInfoFields` lists information needed about your customer to proceed. Provide those fields when approving. You can approve or reject the payment synchronously (recommended) or asynchronously: ### Option 1: Synchronous (recommended) To approve the payment synchronously, respond with a `200 OK` status: * If the `requestedReceiverCustomerInfoFields` array was present in the webhook request and contained mandatory fields, your `200 OK` response **must** include a JSON body containing a `receiverCustomerInfo` object. This object should contain the key-value pairs for the information fields that were requested. * If `requestedReceiverCustomerInfoFields` was not present, was empty, or contained only non-mandatory fields for which you have no information, your `200 OK` response can have an empty body. Example `200 OK` response body when information was requested and provided: ```json theme={null} { "receiverCustomerInfo": { "COUNTRY_OF_RESIDENCE": "US", "FULL_NAME": "John Receiver" } } ``` To reject the payment, respond with a 403 Forbidden status and a JSON body with the following fields: ```json theme={null} { "code": "payment_rejected", "message": "Payment rejected due to compliance policy", "details": { "reason": "failed_counterparty_check", "rejectionReason": "User is in a restricted jurisdiction" } } ``` ### Option 2: Asynchronous Processing If your platform's architecture requires asynchronous processing before approving or rejecting the payment, you can: 1. Return a `202 Accepted` response to acknowledge receipt of the webhook 2. Process the payment asynchronously 3. Call either the `/transactions/{transactionId}/approve` or `/transactions/{transactionId}/reject` endpoint *within 5 seconds* Example of approving asynchronously: ```bash theme={null} curl -X POST "https://api.lightspark.com/grid/2025-10-13/transactions/Transaction:019542f5-b3e7-1d02-0000-000000000005/approve" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "receiverCustomerInfo": { "COUNTRY_OF_RESIDENCE": "US", "FULL_NAME": "John Receiver" } }' ``` Example of rejecting asynchronously: ```bash theme={null} curl -X POST "https://api.lightspark.com/grid/2025-10-13/transactions/Transaction:019542f5-b3e7-1d02-0000-000000000005/reject" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "reason": "RESTRICTED_JURISDICTION" }' ``` > [!WARNING] > > If you choose the asynchronous path, you must call the approve/reject endpoint within 5 seconds, or the payment will be automatically rejected. ## Receive completion notification and credit When the payment completes, you'll receive another webhook notifying you of the completion. The envelope `id` is distinct from the `INCOMING_PAYMENT.PENDING` delivery above — use it for idempotent deduplication. ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000008", "type": "INCOMING_PAYMENT.COMPLETED", "timestamp": "2023-08-15T14:32:00Z", "data": { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000005", "status": "COMPLETED", "type": "INCOMING", "direction": "CREDIT", "source": { "sourceType": "UMA_ADDRESS", "umaAddress": "$mary.sender@thelessgoodbank.com" }, "receivedAmount": { "amount": 50000, "currency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 } }, "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "platformCustomerId": "9f84e0c2a72c4fa", "settledAt": "2023-08-15T14:30:00Z", "createdAt": "2023-08-15T14:25:18Z", "description": "Payment for services", "counterpartyInformation": { "FULL_NAME": "Mary Sender", "BIRTH_DATE": "1985-06-15" }, "reconciliationInstructions": { "reference": "REF-123456789" } } } ``` On completion, the received funds are immediately credited to the account associated with the UMA customer. By default, funds land in the customer's primary internal account. However, you can also set an external account as the default UMA deposit account for the customer, which will cause incoming UMA payments to be deposited into that external account instead. This is useful for customers who prefer to receive UMA payments into a specific bank account directly, for example. To set an external account as the default UMA deposit account for a customer, you can set the `defaultUmaDepositAccount` flag to true when creating the external 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:019542f5-b3e7-1d02-0000-000000000001", "currency": "USD", "accountInfo": { "accountType": "USD_ACCOUNT", "accountNumber": "9876543210", "routingNumber": "021000021", "bankAccountType": "CHECKING", "bankName": "Chase Bank" }, "defaultUmaDepositAccount": true }' ``` ## Failed or refunded payments If an incoming payment cannot be completed after it is accepted, Grid initiates a refund automatically. The transaction moves to `FAILED`, and you'll receive `INCOMING_PAYMENT.REFUND_PENDING` followed by `INCOMING_PAYMENT.REFUND_COMPLETED` or `INCOMING_PAYMENT.REFUND_FAILED`. The transaction's `refund` object tracks the refund status and reference. ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000011", "type": "INCOMING_PAYMENT.REFUND_COMPLETED", "timestamp": "2023-08-15T14:40:00Z", "data": { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000005", "status": "FAILED", "type": "INCOMING", "failureReason": "OFFRAMP_FAILED", "refund": { "reference": "REF-123456789-REFUND", "initiatedAt": "2023-08-15T14:35:00Z", "settledAt": "2023-08-15T14:40:00Z", "status": "COMPLETED", "reason": "TRANSACTION_FAILED" } } } ``` > [!NOTE] > > For the `refund` object field reference, see the [Transaction Lifecycle](/platform-overview/core-concepts/transaction-lifecycle#refund-object) guide. ## Test the inbound flow Use the [UMA Test Wallet](/global-p2p/platform-tools/uma-test-wallet) to send a regtest payment to your customer's UMA address. # Reconciliation Source: https://agents-docs.lightspark.com/global-p2p/sending-receiving-payments/reconciliation Reconciliation of payments This guide explains how to reconcile transactions between your internal systems and the Grid API using two complementary mechanisms: real-time webhooks and periodic queries. > [!TIP] > > Use webhooks for real-time updates and daily queries as a backstop to detect missed events or data drift. ## Handling webhooks Listen for transaction webhooks and apply every status change to your records as it arrives. > [!NOTE] > > A transaction's `status` reports where the funds are now, and it can change after settlement. A bank return moves `COMPLETED` → `FAILED` and `destination.railDetails.status` to `RETURNED`; a dishonored return moves it back to `COMPLETED`. Only `EXPIRED` is terminal. Also listen for `OUTGOING_PAYMENT.REFUND_COMPLETED` and `OUTGOING_PAYMENT.REFUND_FAILED` to track refunds on failed transactions. * **Outbound transactions**: The originating account is debited at transaction creation. If the transaction ultimately fails, a refund is posted back to the originating account. * **Inbound transactions**: The receiving account is credited only on success. A failure before the credit does not change balances. A return after `COMPLETED` does: the credited funds are debited back when `INCOMING_PAYMENT.FAILED` arrives with `source.railDetails.status` `RETURNED`. > [!TIP] > > Grid retries failed webhooks up to 160 times over 7 days with exponential backoff. Use the dashboard to review and remediate webhook delivery issues. **1. Subscribe and verify signatures** Configure your webhook endpoint and verify signatures. See [Webhooks](/global-p2p/platform-tools/webhooks). Sample webhook payload: ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-0000000000ab", "type": "OUTGOING_PAYMENT.COMPLETED", "timestamp": "2025-10-03T15:30:01Z", "data": { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000030", "status": "COMPLETED", "type": "OUTGOING", "direction": "DEBIT", "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "sentAmount": { "amount": 10000, "currency": { "code": "USD", "symbol": "$", "decimals": 2 } }, "receivedAmount": { "amount": 9200, "currency": { "code": "EUR", "symbol": "€", "decimals": 2 } }, "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "platformCustomerId": "customer_12345", "createdAt": "2025-10-03T15:00:00Z", "settledAt": "2025-10-03T15:30:00Z", "description": "Payment for services - Invoice #1234" } } ``` > [!NOTE] > > When funds move over an external rail, that `source` / `destination` object may include a > `railDetails` describing the transfer. It is present for US bank rails and on-chain > transfers today. Its `paymentRail` determines the fields: an > `ONCHAIN` transfer carries `transactionHash` + `network` (e.g. `SOLANA`) to match on a block > explorer, an `ACH` transfer carries its `traceNumber`, a `WIRE` carries its `imad`, > and `RTP` / `FEDNOW` transfers carry their `endToEndId`. Rail identifiers can land shortly > after the status changes, so the webhook payload may not include them yet; retrieve the > transaction (`GET /transactions/{id}`) to read them once settled. **2. Process events idempotently** Use the envelope `id`, `data.id`, and `timestamp` to ensure idempotent handling, updating your internal ledger on each status transition. **3. Keep settled transactions open to late events** Treat `COMPLETED` and `FAILED` as settled, not final. An ACH entry can be returned for up to 60 days after it settles, and a return can be dishonored later still. Keep applying every webhook for a transaction, whenever it arrives, and adjust your records when its `status` changes. Use the daily query below to catch any event your endpoint missed. ## Reconcile via queries Additionally, you can list transactions for a time window and compare with your internal records. > [!NOTE] > > We recommend querying days from `00:00:00.000` to `23:59:59.999` in your preferred timezone. ```bash cURL theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/transactions?startDate=2025-10-01T00:00:00.000Z&endDate=2025-10-01T23:59:59.999Z&limit=100' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Response ```json theme={null} { "data": [ { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000030", "status": "COMPLETED", "type": "OUTGOING", "direction": "DEBIT", "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "sentAmount": { "amount": 10000, "currency": { "code": "USD", "symbol": "$", "decimals": 2 } }, "receivedAmount": { "amount": 9200, "currency": { "code": "EUR", "symbol": "€", "decimals": 2 } }, "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "platformCustomerId": "customer_12345", "description": "Payment for services - Invoice #1234", "exchangeRate": 0.92, "settledAt": "2025-10-03T15:30:00Z", "createdAt": "2025-10-03T15:00:00Z" } ], "hasMore": true, "nextCursor": "eyJpZCI6IlRyYW5zYWN0aW9uOjAxOTU0MmY1LWIzZTctMWQwMi0wMDAwLTAwMDAwMDAwMDAzMCJ9", "totalCount": 45 } ``` ## Troubleshooting * **Missing webhook**: Check delivery logs in the dashboard and ensure your endpoint returns `2xx`. Retries continue for 7 days. * **Mismatched balances**: Re-query the date range and compare each transaction's current `status` to your records. Outbound failures are refunded. An inbound failure before the credit changes no balance; an inbound return after `COMPLETED` debits the credited funds back, and a dishonored return (`railDetails.status` `RETURN_DISHONORED`) credits them again. * **Pagination gaps**: Always follow `nextCursor` until `hasMore` is `false`. # Sending Payments Source: https://agents-docs.lightspark.com/global-p2p/sending-receiving-payments/sending-payments Every payment goes through `POST /quotes`. What changes is where you are sending: 1. To an account — internal or external, with or without currency conversion 2. To an UMA address ## Choosing the right method * **Account**: Pay an internal or external account. Grid converts when the currencies differ and settles over local payment rails (e.g., ACH, RTP, SEPA Instant, PIX, FPS) when they match. Also covers sending to a crypto wallet address when configured. * **UMA**: Send using a Universal Money Address. Ideal for global counterparties on networks. ## Checking limits before you quote Every corridor accepts amounts only within a range. You can read that range before you create a quote, so an out-of-range amount surfaces in your own UI rather than as a `400 AMOUNT_OUT_OF_RANGE` on `POST /quotes`. Which endpoint you use depends on what you know: | You know | Use | Bounds you get | | - | - | - | | A currency corridor | `GET /exchange-rates` | `minSendingAmount` / `maxSendingAmount`, in the sending currency | | A specific recipient | `GET /receiver/external-account/{accountId}` or `GET /receiver/uma/{address}` | Per-currency `min` / `max` in the receiving currency, plus sending-side bounds where Grid can resolve them | > [!WARNING] > > These bounds are approximate. They are priced off a cached rate that refreshes about every > five minutes, and only `POST /quotes` locks a rate and a final amount. Treat what you read > here as a pre-flight check for your UI, not a guarantee that the quote will succeed. ### Across a corridor Use the exchange rates endpoint when you know the currencies but not yet the recipient — populating a currency picker, or validating an amount as the sender types it. ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/exchange-rates?sourceCurrency=USD&destinationCurrency=INR' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ```json Success (200 OK) theme={null} { "data": [ { "sourceCurrency": { "code": "USD", "name": "US Dollar", "symbol": "$", "decimals": 2 }, "sendingAmount": 10000, "minSendingAmount": 100, "maxSendingAmount": 10000000, "destinationCurrency": { "code": "INR", "name": "Indian Rupee", "symbol": "₹", "decimals": 2 }, "destinationPaymentRail": "UPI", "receivingAmount": 825000, "exchangeRate": 0.012121, "fees": { "fixed": 100, "total": 150 }, "updatedAt": "2025-02-05T12:00:00Z" } ] } ``` `minSendingAmount` and `maxSendingAmount` are in the smallest unit of `sourceCurrency` — on this corridor, \$1.00 to \$100,000.00. Omit `destinationCurrency` to get every corridor available from a source currency, each with its own bounds and rail. Repeat the parameter to compare a few: `?sourceCurrency=USD&destinationCurrency=INR&destinationCurrency=GBP`. > [!TIP] > > Pass `sendingAmount` to price a specific amount. `fees.total` varies with the amount sent, > so the default (`10000`) is only representative. ### For a specific recipient Once you have a destination, look it up. The lookup prices the corridor against that particular recipient and returns a `lookupId` you can carry into the quote. ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/receiver/external-account/ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001&sendingCurrency=USD' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ```json Success (200 OK) theme={null} { "accountId": "ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", "lookupId": "Lookup:019542f5-b3e7-1d02-0000-000000000009", "sendingCurrency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 }, "supportedCurrencies": [ { "currency": { "code": "MXN", "name": "Mexican Peso", "symbol": "$", "decimals": 2 }, "estimatedExchangeRate": 18.5, "min": 2000, "max": 185000000, "minSendingAmount": 109, "maxSendingAmount": 10000000 } ], "requiredPayerDataFields": [ { "name": "FULL_NAME", "mandatory": true } ] } ``` Each entry carries bounds on both sides of the conversion. `min` and `max` are in the smallest unit of that entry's `currency` — here MX\$20.00 to MX\$1,850,000.00. `minSendingAmount` and `maxSendingAmount` are in the smallest unit of the response's `sendingCurrency` — \$1.09 to \$100,000.00 — which is the pair you want when your UI collects an amount in the sender's currency. Pass `sendingCurrency` when your customer holds more than one currency. Without it the bounds are priced against the customer's default currency, which may not be the one they intend to send from. `GET /receiver/uma/{address}` returns the same shape for a UMA recipient, with `receiverUmaAddress` in place of `accountId`. A UMA recipient commonly supports several currencies, so expect more than one entry in `supportedCurrencies`, each with its own bounds. > [!NOTE] > > `minSendingAmount` and `maxSendingAmount` are omitted when Grid cannot resolve a > sending-side bound for that currency. Fall back to dividing `min` and `max` by > `estimatedExchangeRate`, and treat the result as looser than the real limit — the sending > leg can impose a bound of its own that the converted receiving bound doesn't reflect. Reuse the `lookupId` on `POST /quotes` to price against the same lookup. It is required for UMA destinations. > [!NOTE] > > Clearing these bounds doesn't guarantee the quote succeeds. Cumulative limits are enforced > at quote time and surface separately as `DAILY_VOLUME_LIMIT_EXCEEDED` (HTTP 429), and a > per-transaction ceiling can surface as `TRANSACTION_SIZE_LIMIT_EXCEEDED`. Handle both > alongside `AMOUNT_OUT_OF_RANGE`. ## Sending to an Account Every payment to an internal or external account goes through `POST /quotes`, whether or not the currencies differ. The quote prices the transfer — amounts, fees, and, when converting, a locked exchange rate — and creates the transaction that carries the money. What varies is when you execute it: * **In one request.** Set `immediatelyExecute` and Grid creates and executes the quote together. Use this when you don't need to put rate or fee details in front of your user before the money moves. * **In two steps.** Create the quote, show your user what the transfer will cost, then call execute before the quote expires. Use this whenever your UX surfaces rates or fees — which includes same-currency transfers, where there is no exchange rate but there can still be fees worth showing. ### Create and execute a quote **1. Create a quote** Request a quote to lock in the exchange rate and get transfer details: ```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:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123", "paymentRail": "ACH" }, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 10000, "remittanceInformation": "INV-12345", "statementDescriptor": "INVOICE", "description": "Payment for services - Invoice #1234" }' ``` ```json Success (200 OK) theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000025", "status": "PENDING", "createdAt": "2025-10-03T15:00:00Z", "expiresAt": "2025-10-03T15:15:00Z", "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "sendingCurrency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 }, "receivingCurrency": { "code": "EUR", "name": "Euro", "symbol": "€", "decimals": 2 }, "totalSendingAmount": 10000, "totalReceivingAmount": 9200, "exchangeRate": 0.92, "feesIncluded": 50, "platformFeesIncluded": 0, "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000030", "description": "Payment for services - Invoice #1234" } ``` > [!NOTE] > > **Same-currency transfers use this exact request.** The two currencies simply match, and > the quote comes back with an `exchangeRate` of `1` — the fee fields are still populated. > Add `"immediatelyExecute": true` to create and execute in this one request and skip the > next two steps. > [!NOTE] > > **Locked currency side** determines which amount is fixed: > > * `SENDING`: Lock the sending amount (receiving amount calculated based on exchange rate) > * `RECEIVING`: Lock the receiving amount (sending amount calculated based on exchange rate) > [!TIP] > > The `paymentRail` field is optional. If omitted, Grid selects a default rail for the destination. Specify a rail (e.g., `ACH`, `ACH_SAME_DAY`, `WIRE`, `RTP`, `FEDNOW`) when you need to control which payment network processes the transfer. `ACH_SAME_DAY` is never selected for you: it requests same-business-day settlement and is priced separately. It is capped at $1,000,000 per entry by the NACHA same-day limit (rising to $10,000,000 on 2027-09-17); a payout above that is rejected, so send it over `ACH` instead. > [!NOTE] > > `remittanceInformation` is optional. Use it to send a reference that travels with the payment to the recipient (max 80 characters). This populates the ACH Addenda record, FedNow/RTP remittance information, or wire OBI field depending on the payment rail. `statementDescriptor` is optional and used only on ACH: it sets the up-to-10-character Company Entry Description the recipient's bank shows on their statement, such as `PAYROLL`. > [accordion] Including purpose of payment > > For external account or UMA destinations, some payment corridors require a purpose of payment. Include the `purposeOfPayment` field in the quote request: > > ```json theme={null} > { > "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:..." }, > "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:..." }, > "lockedCurrencySide": "SENDING", > "lockedCurrencyAmount": 10000, > "purposeOfPayment": "GOODS_OR_SERVICES" > } > ``` > > **Purpose of payment codes:** > > * `GIFT` - Personal gift > * `SELF` - Transfer to yourself > * `GOODS_OR_SERVICES` - Payment for goods or services > * `EDUCATION` - Education-related expenses > * `HEALTH_OR_MEDICAL` - Medical or healthcare expenses > * `REAL_ESTATE_PURCHASE` - Real estate transaction > * `TAX_PAYMENT` - Tax payment > * `LOAN_PAYMENT` - Loan repayment > * `UTILITY_BILL` - Utility bill payment > * `DONATION` - Charitable donation > * `TRAVEL` - Travel-related expenses > * `FAMILY_SUPPORT` - Family support or remittance > * `SALARY_PAYMENT` - Salary or wage payment > * `SERVICE_CHARGES` - Service charges > * `OTHER` - Other purpose (may require additional documentation) > > **For China business payouts:** > > Business payments to China must use one of the purposes below. [Supporting documents](/payouts-and-b2b/payment-flow/send-payment#supporting-documents) lists the documents each one needs. > > * `EXPORTED_GOODS_PREPAYMENT` - Exported goods, paid before shipping > * `EXPORTED_GOODS_POSTPAYMENT` - Exported goods, paid after delivery > * `COMMISSION_ON_GOODS` - Commission on goods > * `COMMISSION_ON_SERVICES` - Commission on services > * `ACCOUNTING_SERVICES` - Accounting services > * `EXHIBITION_SERVICES` - Exhibition services > * `OFFICE_EXPENSES` - Office expenses > * `DELIVERY_FEES` - Delivery fees > * `HOTEL_ACCOMMODATION` - Hotel accommodation > * `TRAVEL` - Travel, listed above and also valid for China **2. Review quote details** Skip this step and the next by setting `immediatelyExecute` on the quote. Otherwise, before executing, review the quote to ensure: * Exchange rate is acceptable * Fees are as expected * Receiving amount meets requirements * Quote hasn't expired (check `expiresAt`) > [!WARNING] > > Quote expiration depends on the corridor but is typically \~5 minutes or greater. If expired, create a new quote to get an updated exchange rate. > [!NOTE] > > Quoted fees may fluctuate between quotes. Some underlying fee components > are denominated in the receiving currency, so their equivalent in the > sending currency moves with the FX rate. The fee shown in the quote is > locked only for the lifetime of that quote — a new quote for the same > transfer may return a different total. **3. Execute the quote** Confirm and execute the quote to initiate the transfer: ```bash theme={null} curl -X POST 'https://api.lightspark.com/grid/2025-10-13/quotes/Quote:019542f5-b3e7-1d02-0000-000000000025/execute' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ```json Success (200 OK) theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000025", "status": "PROCESSING", "createdAt": "2025-10-03T15:00:00Z", "expiresAt": "2025-10-03T15:15:00Z", "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "sendingCurrency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 }, "receivingCurrency": { "code": "EUR", "name": "Euro", "symbol": "€", "decimals": 2 }, "totalSendingAmount": 10000, "totalReceivingAmount": 9200, "exchangeRate": 0.92, "feesIncluded": 50, "platformFeesIncluded": 0, "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000030" } ``` > [!TIP] > > Once executed, the quote creates a transaction and the transfer begins processing. The `transactionId` can be used to track the payment. > [!NOTE] > > **Real-time funding sources:** If your quote uses a real-time funding source (USDC, BTC, RTP, or FedNow), you don't call the execute endpoint. Instead, send a payment to the account specified in the quote's `paymentInstructions`. Grid detects the deposit and processes the transfer automatically. **4. Monitor completion** After execution, a transaction is created and progresses through `PENDING` → `PROCESSING` → `COMPLETED` or `FAILED`. You'll receive `OUTGOING_PAYMENT.` webhooks as the transaction progresses. The webhook body contains the full transaction resource: ```json theme={null} { "type": "OUTGOING_PAYMENT.COMPLETED", "data": { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000030", "status": "COMPLETED", "type": "OUTGOING", "direction": "DEBIT", "sentAmount": { "amount": 10000, "currency": { "code": "USD", "decimals": 2 } }, "receivedAmount": { "amount": 9200, "currency": { "code": "EUR", "decimals": 2 } }, "exchangeRate": 0.92, "settledAt": "2025-10-03T15:30:00Z", "quoteId": "Quote:019542f5-b3e7-1d02-0000-000000000025" }, "timestamp": "2025-10-03T15:31:00Z" } ``` If a transaction fails, Grid initiates a refund automatically. You'll receive `OUTGOING_PAYMENT.REFUND_PENDING` followed by `OUTGOING_PAYMENT.REFUND_COMPLETED` or `OUTGOING_PAYMENT.REFUND_FAILED`. The transaction's `refund` object tracks the refund status and reference. > [!NOTE] > > For the full state diagram, refund object details, and all webhook scenarios (including bank returns and manual cancellations), see the [Transaction Lifecycle](/platform-overview/core-concepts/transaction-lifecycle) guide. ## Funding with cryptocurrencies Transfers can be funded via USDC and BTC on popular blockchains including Solana, Base, Lightning and Spark. When you create a quote specifying the source currency as USDC or BTC, the response includes payment instructions for multiple funding options. ### Supported blockchains | Blockchain Network | Cryptocurrencies | | - | - | | Solana | USDC | | Base | USDC | | Tron | USDT | | Polygon | USDC | | Ethereum | USDC, USDT | | Plasma | USDT | | Arbitrum | USDC, USDT | | Lightning | BTC | | Spark | BTC | ### Create a quote for USDC-funded transfer Request a quote that provides blockchain funding options: ```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:019542f5-b3e7-1d02-0000-000000000001", "currency": "USDC" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 10000, "description": "Payment for services - Invoice #1234" }' ``` The response includes an array of payment instructions, including blockchain wallet addresses for USDC and invoices for BTC: ```json Success (200 OK) theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000025", "status": "PENDING", "createdAt": "2025-10-03T15:00:00Z", "expiresAt": "2025-10-03T15:15:00Z", "source": { "sourceType": "REALTIME_FUNDING", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "currency": "USDC" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "sendingCurrency": { "code": "USDC", "name": "USD Coin", "symbol": "USDC", "decimals": 2 }, "receivingCurrency": { "code": "EUR", "name": "Euro", "symbol": "€", "decimals": 2 }, "totalSendingAmount": 10000, "totalReceivingAmount": 9200, "exchangeRate": 0.92, "feesIncluded": 50, "platformFeesIncluded": 0, "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000030", "paymentInstructions": [ { "accountOrWalletInfo": { "accountType": "SOLANA_WALLET", "assetType": "USDC", "address": "4Nd1m6Qkq7RfKuE5vQ9qP9Tn6H94Ueqb4xXHzsAbd8Wg" } }, { "accountOrWalletInfo": { "accountType": "BASE_WALLET", "assetType": "USDC", "address": "0x1234567890abcdef1234567890abcdef12345678" } } ] } ``` ### Transaction processing Grid automatically detects blockchain deposits and processes the transfer once funds are received: **1. Send USDC to the provided address** Transfer the exact amount of USDC specified in `totalSendingAmount` to your chosen blockchain wallet address. **2. Grid detects the deposit** Grid monitors the blockchain for incoming deposits. You'll receive an `INTERNAL_ACCOUNT.BALANCE_UPDATED` webhook when the deposit is confirmed: ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000040", "type": "INTERNAL_ACCOUNT.BALANCE_UPDATED", "timestamp": "2025-10-03T15:05:00Z", "data": { "id": "InternalAccount:019542f5-b3e7-1d02-0000-000000000025", "balance": { "amount": 10000, "currency": { "code": "USDC", "decimals": 2 } }, "totalBalance": { "amount": 10000, "currency": { "code": "USDC", "decimals": 2 } } } } ``` **3. Transfer executes automatically** Once the deposit is confirmed, Grid executes the cross-currency transfer. You'll receive `OUTGOING_PAYMENT.` webhooks as the transfer progresses: ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000041", "type": "OUTGOING_PAYMENT.COMPLETED", "timestamp": "2025-10-03T15:31:00Z", "data": { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000030", "status": "COMPLETED", "type": "OUTGOING", "direction": "DEBIT", "sentAmount": { "amount": 10000, "currency": { "code": "USDC", "decimals": 2 } }, "receivedAmount": { "amount": 9200, "currency": { "code": "EUR", "decimals": 2 } }, "exchangeRate": 0.92, "settledAt": "2025-10-03T15:30:00Z", "quoteId": "Quote:019542f5-b3e7-1d02-0000-000000000025" } } ``` See the [Transaction Lifecycle](/platform-overview/core-concepts/transaction-lifecycle) guide for all possible status transitions and refund handling. ## Sending to an UMA Address Send to an UMA address when the receiver is identified by their UMA handle, e.g., \$[alice@example.com](mailto:alice@example.com). You'll look up the receiver, create a quote, and then fund. ### Look up the recipient ```bash theme={null} curl -X GET "https://api.lightspark.com/grid/2025-10-13/receiver/uma/\$recipient@example.com?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` #### Response ```json Success (200 OK) theme={null} { "receiverUmaAddress": "$recipient@example.com", "lookupId": "Lookup:019542f5-b3e7-1d02-0000-000000000009", "supportedCurrencies": [ { "currency": { "code": "EUR", "name": "Euro", "symbol": "€", "decimals": 2 }, "estimatedExchangeRate": 0.92, "min": 100, "max": 10000000 }, { "currency": { "code": "BRL", "name": "Brazilian Real", "symbol": "R$", "decimals": 2 }, "estimatedExchangeRate": 5.15, "min": 100, "max": 10000000 } ], "requiredPayerDataFields": [ { "name": "FULL_NAME", "mandatory": true }, { "name": "BIRTH_DATE", "mandatory": true } ] } ``` The response includes supported currencies and any required payer information fields. If the receiver's VASP requires payer data, include it in `senderCustomerInfo` (applies to either tab). ### Create a quote ```bash Just-in-time funding 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": "USD" }, "destination": { "destinationType": "UMA_ADDRESS", "umaAddress": "$recipient@example.com" }, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 10000, "description": "Invoice #1234 payment", "senderCustomerInfo": { "FULL_NAME": "John Sender", "BIRTH_DATE": "1985-06-15" } }' ``` ```bash Prefunded (use internal account as source) 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": "ACCOUNT", "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, "destination": { "destinationType": "UMA_ADDRESS", "umaAddress": "$recipient@example.com" }, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 10000, "description": "UMA payment from prefunded balance" }' ``` ### Execute payment (just-in-time) Use the `paymentInstructions` from the quote to instruct your bank to push funds. Include the exact `reference` provided. ### Execute payment (prefunded) Existing internal account balances will be used to fund the payment. Use the lookup Id above to confirm the payment and execute the quote. #### Execute the quote ```bash theme={null} curl -X POST "https://api.lightspark.com/grid/2025-10-13/quotes/Quote:019542f5-b3e7-1d02-0000-000000000025/execute" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Executing the quote creates a transaction that draws from your internal account and delivers to the recipient associated with the UMA address. #### Track status Listen for `OUTGOING_PAYMENT` webhooks until the transaction reaches `COMPLETED` or `FAILED`. You can also query for the transaction with the following snippet: ```bash theme={null} curl -X GET "https://api.lightspark.com/grid/2025-10-13/transactions/Transaction:019542f5-b3e7-1d02-0000-000000000030" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ## Strong Customer Authentication (EU customers) Customers in SCA-regulated regions (in practice the EU: EUR / USDC) must confirm payments with Strong Customer Authentication. When it applies, the quote comes back `PENDING_AUTHORIZATION` carrying an `scaChallenge` that you authorize before the transfer is released; for every other customer nothing changes. See [Per-transaction authorization](/platform-overview/sca/per-transaction-authorization) for the full walkthrough. # Core Concepts Source: https://agents-docs.lightspark.com/global-p2p/terminology Core concepts and terminology for the Grid API There are several key entities in the Grid API: **Platform**, **Customers**, **Internal Accounts**, **External Accounts**, **Quotes**, **Transactions**, and **UMA Addresses**. ![Entity relationships diagram showing platform, customers, internal accounts, external accounts, transactions, and UMA addresses](https://mintcdn.com/agents-docs/qL8kqlSYbvexR8cg/images/entities-light.svg?fit=max\&auto=format\&n=qL8kqlSYbvexR8cg\&q=85\&s=e5d51454ebea529d7d9219a140ab7e1e) ## Businesses, People, and Accounts ### Platform Your **platform** is you! It's the top-level entity that integrates with the Grid API. The platform: * Has its own configuration (webhook endpoint, supported currencies, API tokens, etc.) * A platform can have many customers both business and individual * Manages multiple customers and their accounts * Can hold platform-owned internal accounts for settlement and liquidity management * Acts as the integration point between your application and the open Money Grid ### Customers **Customers** are your end users who send and receive payments through your platform. Each customer: * Can be an individual or business entity * Has a KYC/KYB status that determines their ability to transact. If you are a regulated financial institution, this will typically be `APPROVED` since you do the KYC/KYB yourself. * Is identified by both a system-generated ID and optionally your platform-specific customer ID * May have associated internal accounts and external accounts * May have a unique **UMA address** (e.g., `$john.doe@yourdomain.com`). If you don't assign an UMA address when creating a customer, they will be assigned a system-generated one. ### Internal Accounts **Internal accounts** are Grid-managed accounts that hold balances in specific currencies. They can belong to either: * **Platform internal accounts** - Owned by the platform for settlement, liquidity, and float management * **Customer internal accounts** - Associated with specific customers for holding funds Internal accounts: * Have balances in a single currency (USD, EUR, MXN, etc.) * Can be funded via bank transfers or crypto deposits using payment instructions * Are used as sources or destinations for transactions instantly 24/7/365 * Track available balance for sending payments or receiving funds > [!NOTE] > > **Global Accounts** are a special kind of internal account, powered by a self-custody embedded [Spark](https://spark.money) wallet that Grid provisions for your customers to hold a stablecoin balance. They behave like any other internal account on the way in, but every outbound transfer must be authorized by a customer signature. See [Global Accounts](/payouts-and-b2b/global-accounts/overview) for the full overview. ### External Accounts **External accounts** are traditional bank accounts, crypto wallets, or other payment instruments connected to customers for on-ramping or off-ramping funds. Each external account: * Are associated with a specific customer or the platform * Represents a real-world bank account (with routing number, account number, IBAN, etc.), wallet, or payment instrument * Has an associated beneficiary (individual or business) who receives payments from the customer or platform * Has a status indicating screening status (ACTIVE, PENDING, INACTIVE, etc.) * Can be used as a destination for quote-based transfers or same currency transfers like withdrawals * For pullable sources like debit cards or ACH pulls, an external account can be used as a source for transfers-in to fund internal accounts or to fund cross-border transfers via quotes. ## Entity Examples by Use Case Understanding how entities map to your specific use case helps clarify your integration architecture. Here are common examples: ### B2B Payouts Platform (e.g., Bill.com, Routable) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The payouts platform itself | Your company providing AP automation | | **Customer** | Businesses sending payments to vendors | Acme Corp (your client company) | | **External Account** | Vendors/suppliers receiving payments | Office supply vendor, freelance contractor | **Flow**: Acme Corp (customer) uses your platform to pay their vendor invoices → funds move from Acme's internal account → to vendor's external bank account ### Direct Rewards Platform (Platform-Funded Model) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The app paying rewards directly to users | Your cashback app | | **Customer** | (Not used in this model) | N/A | | **External Account** | End users' crypto wallets receiving rewards | Sarah's self-custody Bitcoin wallet | **Flow**: Your platform sends micro-payouts directly from platform internal accounts → to users' external crypto wallets at scale. Common for cashback apps where the platform earns affiliate commissions and shares them with users. ### White-Label Rewards Platform (Customer-Funded Model) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The rewards infrastructure provider | Your white-label rewards API | | **Customer** | Brands or merchants running reward campaigns | Nike, Starbucks | | **External Account** | End users' crypto wallets receiving rewards | Sarah's self-custody Bitcoin wallet | **Flow**: Nike (customer) funds their internal account → your platform sends rewards on their behalf → to users' external crypto wallets. Common for brand loyalty programs where merchants manage their own reward budgets. ### Remittance/P2P App (e.g., Wise, Remitly) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The remittance service | Your money transfer app | | **Customer** | Both sender and recipient of funds | Maria (sender in US), Juan (recipient in Mexico) | | **External Account** | Bank accounts for funding/receiving | Maria's US bank (funding), Juan's Mexican bank (receiving funds) | **Flow**: Maria (customer) funds transfer from her external account → to Juan (also a customer) → who receives funds in his external bank account. Alternatively, Maria could send to Juan's UMA address directly. ## Transactions and Addressing Entities ### Quotes **Quotes** provide locked-in exchange rates and payment instructions for transfers. A quote: * Specifies a source (internal account, customer ID, or the platform itself) and destination (internal/external account or UMA address) * Locks an exchange rate for a short period (typically 1-5 minutes) or can be immediately executed with the `immediatelyExecute` flag * Calculates total fees and amounts for currency conversion * Provides payment instructions for funding the transfer if needed, or can be funded via an internal account balance. * Must be executed before it expires * Creates a transaction when executed ### Transactions **Transactions** represent completed or in-progress payment transfers. Each transaction: * Has a type (INCOMING or OUTGOING), keyed on whether the destination is an internal account * Has a status (PENDING, COMPLETED, FAILED, etc.) * References a customer (sender for outgoing, recipient for incoming) or a platform internal account * Specifies source and destination (accounts or UMA addresses) * Includes amounts, currencies, and settlement information * May include counterparty information for compliance purposes if required by your platform configuration Transactions are created when a quote is executed, incoming or outgoing. This covers same-currency and cross-currency transfers alike. ### UMA Addresses (optional) **UMA addresses** are human-readable payment identifiers that follow the format `$username@domain.com`. They: * Uniquely identify entities on the Grid network * Enable sending and receiving payments across different platforms without knowing the recipient's underlying account details or personal information * Support currency negotiation and cross-border transfers * Work similar to email addresses but for payments * Are an optional UX improvement for some use cases. Use of UMA addresses is not required in order to use the Grid API. # Source: https://agents-docs.lightspark.com/index ![Grid](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/logo/grid-logo.svg?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=4e647b99a5904f6d46e499e286273cdb) Documentation Build applications and financial products that move money globally across fiat, stablecoins, and Bitcoin with a single API. [Introducing Global Accounts Create global multicurrency accounts with one integration Start building](/global-accounts/demo) ![](https://mintcdn.com/agents-docs/qL8kqlSYbvexR8cg/images/icons/IconNewspaper2.svg?fit=max\&auto=format\&n=qL8kqlSYbvexR8cg\&q=85\&s=2ff3b3c96a1cd8efd2d62da92a7cfae4) Understand GridLearn how the platform works before you integrate [What is Grid?](/platform-overview/introduction/what-is-grid) [Capabilities](/platform-overview/capabilities) [Entities & relationships](/platform-overview/core-concepts/entities) ![](https://mintcdn.com/agents-docs/qL8kqlSYbvexR8cg/images/icons/grid-icon.svg?fit=max\&auto=format\&n=qL8kqlSYbvexR8cg\&q=85\&s=552190e5279f3865eaa6fc35622d8604) Start buildingJump into a quickstart guide [Send a payout](/payouts-and-b2b/quickstart) [On-ramp to crypto](/ramps/quickstart) Capabilities Primitives for moving money [View all capabilities](/platform-overview/capabilities) Send Move funds to any destination Receive Accept incoming funds Convert Exchange currencies and assets Hold Store multi-currency balances Ramp Bridge fiat and crypto Bill Invoice and collect payments Program Create programmable flows Identify KYC/KYB verification What you can build Combine primitives, ship products [View all use cases](/platform-overview/use-cases) Building on Grid ![Coinbase](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/logos/coinbase.svg?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=322706a9ce40724856a67910641f5895) ![SoFi](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/logos/sofi.svg?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=e190f516aa83f08a3b25b8461a0f47d6) ![Nu](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/logos/nu.svg?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=e4ba7b5cd8ea34691b6fbfe1ff4701ef) ![Revolut](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/logos/revolut.svg?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=a2e5acf8fcc49cb019bc64ec0314ac71) ![Bitso](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/logos/bitso.svg?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=a0ad07e6aa58561bad6f4c8d3452d65a) ## Resources Quick links [Grid GitHub repo](https://github.com/lightsparkdev/grid-api) ![](https://mintcdn.com/agents-docs/qL8kqlSYbvexR8cg/images/icons/hammer.svg?fit=max\&auto=format\&n=qL8kqlSYbvexR8cg\&q=85\&s=cece7c1c3b1ab442d562b64edf0d3f34) Tools and sandbox [Open](/payouts-and-b2b/platform-tools/sandbox-testing) ![](https://mintcdn.com/agents-docs/qL8kqlSYbvexR8cg/images/icons/IconPostman.svg?fit=max\&auto=format\&n=qL8kqlSYbvexR8cg\&q=85\&s=b5df0835a9f60e3854f1cc71178a23b7) Postman collection [Open](/payouts-and-b2b/platform-tools/postman-collection) Need help? [Contact us](https://www.lightspark.com/contact) [Request sandbox access](https://www.lightspark.com/contact) [Book a Live Demo](https://www.lightspark.com/contact) Socials [X (Twitter)](https://x.com/lightspark) [LinkedIn](https://linkedin.com/company/lightspark-group) # ACH Pull Source: https://agents-docs.lightspark.com/payouts-and-b2b/depositing-funds/ach-pull Debit a customer's US bank account to fund their Lightspark account An ACH pull debits a customer's bank account in the United States and credits their Lightspark internal account. You supply the account and routing number. Lightspark originates the debit through its partner bank as an ACH WEB entry, the NACHA code for a consumer debit authorized online. Funds are typically available two business days after initiation. A pull differs from a push deposit in one way: the customer's bank can return a debit after it settles. Most returns arrive within two banking days. A consumer who says the debit was unauthorized has 60 calendar days. Because of this, Lightspark validates the account before the first debit, holds pulled funds through the common return window, and reverses the credit when a return arrives. You capture the customer's authorization and keep the record. This page covers what Lightspark checks before the first debit, how long each step takes, when the funds become spendable, and what a return does. ## Prerequisites * Your platform is enabled for ACH pull. Lightspark turns it on after compliance approval. Until then, a quote funded from an external account returns `501 NOT_IMPLEMENTED`. * The customer's verification status is `APPROVED`. * You have the customer's authorization for the debit. For what it must contain, see [Your obligations](#your-obligations). ## Fund an account with an ACH pull **1. Register the bank account** Create the [external account](/payouts-and-b2b/depositing-funds/external-accounts) with `accountType` set to `USD_ACCOUNT`. `routingNumber` is nine digits. `bankAccountType` is `CHECKING` or `SAVINGS`. Lightspark uses it to set the ACH transaction code, and a wrong value makes the bank send a notification of change. Lightspark starts validating the account as soon as you create it. ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", "currency": "USD", "platformAccountId": "user_123_primary_bank", "accountInfo": { "accountType": "USD_ACCOUNT", "accountNumber": "123456789", "routingNumber": "021000021", "bankAccountType": "CHECKING", "bankName": "Chase Bank", "beneficiary": { "beneficiaryType": "INDIVIDUAL", "fullName": "John Doe", "birthDate": "1990-01-15", "nationality": "US", "address": { "line1": "123 Main Street", "city": "San Francisco", "state": "CA", "postalCode": "94105", "country": "US" } } } }' ``` **2. Wait for validation** Lightspark confirms with account validation providers that the account exists, is open, and accepts ACH. For most US accounts this completes within seconds of registration. When no provider can answer for the account's bank, Lightspark sends a \$0 prenote and waits out the bank's return window, which takes three banking days. Validation happens once per account, not per pull. A change to the account number starts it again. Until validation completes, a quote from the account returns `409 BANK_ACCOUNT_VALIDATION_PENDING`. The account has not been rejected. Tell the customer to wait, not to re-enter their details. An account that fails validation returns `400 INVALID_BANK_ACCOUNT` on every later attempt. Ask the customer for a different account. > [!NOTE] > > Validation checks the account, not the holder. Lightspark also records whether the holder name you supplied matches the bank's records, but a name mismatch does not block a pull. **3. Create and execute the quote** Create a [quote](/platform-overview/core-concepts/quote-system) whose source is the external account and whose destination is the same customer's USD internal account. Set `immediatelyExecute: true` to send the debit as soon as the quote is created. Three rules apply to pull quotes: both accounts belong to the same customer, the quote is USD to USD, and you cannot add a platform fee. Send an `Idempotency-Key` so a retried request does not debit the customer twice. `statementDescriptor` is what the customer sees on their bank statement. It is up to 10 printable ASCII characters and defaults to `FUNDING`. Use a value the customer recognizes as your platform. ```bash cURL 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' \ -H 'Idempotency-Key: 7f3a9c1e-pull-2026-10-19' \ -d '{ "source": { "sourceType": "ACCOUNT", "accountId": "ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, "destination": { "destinationType": "ACCOUNT", "accountId": "InternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 50000, "immediatelyExecute": true, "statementDescriptor": "ACMEFUND", "description": "Top up from linked bank account" }' ``` **4. Track the transaction** A pull is an `INCOMING` transaction with a `DEBIT` direction. From the moment Lightspark sends the debit to the bank, the amount counts toward the internal account's `totalBalance` but not its spendable balance. | Stage | Transaction status | Webhook | Spendable | | - | - | - | - | | Quote created | `PENDING` | `INCOMING_PAYMENT.PENDING` | No | | Debit sent to the bank | `PROCESSING` | `INCOMING_PAYMENT.PROCESSING` | No | | Bank settles, hold running | `PROCESSING` | None | No | | Hold released | `COMPLETED` | `INTERNAL_ACCOUNT.BALANCE_UPDATED`, then `INCOMING_PAYMENT.COMPLETED` | Yes | | Bank rejects, or returns the debit | `FAILED` | `INCOMING_PAYMENT.FAILED` | No | `source.railDetails` on the transaction carries the ACH leg: `paymentRail`, a status of `PENDING`, `SETTLED`, `RETURNED` or `FAILED`, the masked bank account, and the statement descriptor. > [!WARNING] > > `COMPLETED` is not terminal. A return can arrive after `COMPLETED` and move the transaction to `FAILED`, so apply each status webhook as it arrives. The webhook that tells you the funds are spendable: ```json INCOMING_PAYMENT.COMPLETED theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000021", "type": "INCOMING_PAYMENT.COMPLETED", "timestamp": "2026-10-23T21:05:00Z", "data": { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000020", "status": "COMPLETED", "type": "INCOMING", "direction": "DEBIT", "source": { "sourceType": "ACCOUNT", "accountId": "ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", "currency": "USD", "railDetails": { "paymentRail": "ACH", "status": "SETTLED", "settledAt": "2026-10-21T21:00:00Z", "statementDescriptor": "ACMEFUND", "bankAccount": { "accountHolderName": "John Doe", "routingNumber": "021000021", "accountNumber": "****6789" } } }, "destination": { "destinationType": "ACCOUNT", "accountId": "InternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "platformCustomerId": "user_123", "receivedAmount": { "amount": 50000, "currency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 } }, "settledAt": "2026-10-21T21:00:00Z", "createdAt": "2026-10-20T18:12:04Z", "description": "Top up from linked bank account" } } ``` `settledAt` is when the bank settled the debit. The webhook arrives when the hold releases, which is later. ## Timing | Step | Takes | | - | - | | Account validation | Seconds for most accounts. Three banking days when the prenote is needed. Once per account. | | Bank settlement | Same business day when the debit reaches the bank before its cutoff, otherwise the next business day. | | Settlement hold | 1, 2, or 5 banking days after settlement, by risk tier. Ends at the close of the banking day in US Eastern time. | Banking days exclude weekends and Federal Reserve holidays. A first pull from a newly registered account that needs the prenote and lands in the standard tier becomes spendable about five banking days after registration. A later pull from the same account becomes spendable two to three banking days after you create the quote. ## Settlement holds Funds from an ACH pull are not available immediately, because the debit can be reversed. When they become available depends on the risk profile of the transaction and the customer. Lightspark chooses the tier per deposit based on the account's history and the amount. | Tier | Hold | Applies when | | - | - | - | | Trusted | 1 banking day | The customer's account is at least 90 days old, has at least five completed pulls and no returned pull, and its settled balance already covers the amount. | | Standard | 2 banking days | Everything else. | | Extended | 5 banking days | The account has any returned pull, or the amount is over \$5,000. | Held funds show in `totalBalance` and not in `balance`. Show the customer the pending amount and the expected availability so they do not mistake the wait for a lost deposit. Lightspark can adjust the tiers for your platform as your return history builds. The hold covers the common return window, not the whole one. When a return arrives after the hold releases, Lightspark debits the amount back from the account. For details, see [Returns](#returns). ## Returns A return is the customer's bank sending the debit back. ACH has no card-style chargeback. The bank returns a debit when the account has insufficient funds, is closed, cannot be found, or has an invalid number, and when the customer tells their bank the debit was unauthorized. | Return | Window | Lightspark's response | | - | - | - | | Insufficient funds, closed account, account not found, invalid account number | Two banking days after settlement, so usually inside the hold | Lightspark reverses the credit and blocks further pulls from a closed, missing, or invalid account. Register a corrected account to continue. | | Unauthorized, reported by a consumer | 60 calendar days after settlement | Lightspark reverses the credit, holds the customer's funds while it investigates, and contacts you. Have your authorization record ready. | | Unauthorized, reported by a business | Two banking days after settlement | Lightspark reverses the credit and contacts you. | ### What you receive Every return arrives as one webhook, whether it lands during the hold or weeks after `COMPLETED`. | Field | Value | | - | - | | Webhook type | `INCOMING_PAYMENT.FAILED` | | `data.status` | `FAILED` | | `data.failureReason` | `QUOTE_EXECUTION_FAILED` | | `data.source.railDetails.status` | `RETURNED` | | `data.source.railDetails.returnedAt` | When Lightspark recorded the return | | `data.source.railDetails.returnCode` | The NACHA return reason code, such as `R01` (insufficient funds) or `R10` (customer advises unauthorized) | | `data.source.railDetails.returnCategory` | The Nacha return-rate category the code counts toward: `ADMINISTRATIVE` or `UNAUTHORIZED`. Absent for a code in neither, such as `R01`. | Group returns by `returnCode` to track your return rate for each reason, or by `returnCategory` for Nacha's thresholds. NACHA may add codes, so accept any value of the form `R` followed by two digits. Check `railDetails.status` to tell a return from a failure that happened before any money moved. A pull the bank rejected outright has `railDetails.status` set to `FAILED`. An insufficient-funds return, two days after the debit settled: ```json INCOMING_PAYMENT.FAILED theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000022", "type": "INCOMING_PAYMENT.FAILED", "timestamp": "2026-10-23T15:02:00Z", "data": { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000020", "status": "FAILED", "type": "INCOMING", "direction": "DEBIT", "failureReason": "QUOTE_EXECUTION_FAILED", "source": { "sourceType": "ACCOUNT", "accountId": "ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", "currency": "USD", "railDetails": { "paymentRail": "ACH", "status": "RETURNED", "settledAt": "2026-10-21T21:00:00Z", "returnedAt": "2026-10-23T15:00:00Z", "returnCode": "R01", "statementDescriptor": "ACMEFUND", "bankAccount": { "accountHolderName": "John Doe", "routingNumber": "021000021", "accountNumber": "****6789" } } }, "destination": { "destinationType": "ACCOUNT", "accountId": "InternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "platformCustomerId": "user_123", "receivedAmount": { "amount": 50000, "currency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 } }, "settledAt": "2026-10-21T21:00:00Z", "createdAt": "2026-10-20T18:12:04Z", "description": "Top up from linked bank account" } } ``` This is the same transaction id as the `INCOMING_PAYMENT.COMPLETED` webhook above, now `FAILED`. `returnCode` says why the bank returned it. `R01` counts toward neither Nacha category, so the payload has no `returnCategory`. ### What happens to the money * If the funds are still held, Lightspark cancels the pending credit. The customer's spendable balance never changed. * If the funds were already spendable, Lightspark debits the internal account for the returned amount. * If the balance does not cover the returned amount, the account goes to zero and your platform owes the shortfall under your agreement with Lightspark. Lightspark does not re-initiate a returned pull. Do not create a new pull for the same debit unless the customer authorizes it again and the cause of the return is resolved. ## Limits Your agreement sets four ACH debit limits for your platform: a per-transaction cap, a daily cap, a rolling 30-day cap, and a cap on total unsettled volume, meaning pulls the bank has not yet settled. Lightspark reviews them as your volume and return history grow. Lightspark rejects a quote over the per-transaction or daily limit when you create it, with `400 TRANSACTION_SIZE_LIMIT_EXCEEDED` or `429 DAILY_VOLUME_LIMIT_EXCEEDED`. A pull that would breach the rolling or unsettled caps fails after execution, with `INCOMING_PAYMENT.FAILED` and `failureReason` set to `QUOTE_EXECUTION_FAILED`, before any debit reaches the bank. ## Your obligations Lightspark originates pulls as a NACHA Third-Party Sender, and your platform agrees to the NACHA Operating Rules through the master services agreement. For pulls, that means: * **Authorization before every pull.** Capture it from the customer in your own flow. It must state that the customer authorizes the debit, the amount or how it is determined, the timing, the account to be debited, the date, how the customer can revoke it, and the customer's electronic signature or an equivalent authentication. Give the customer a copy they can keep. * **Retention.** Keep each authorization for two years after it ends or is revoked, and provide it to Lightspark within 24 hours of a request. The customer's bank can ask for proof of authorization, and the response goes through Lightspark. * **Revocation.** When a customer revokes, stop before the next pull. A debit after revocation is an unauthorized return. * **Return rates.** Keep returns below the NACHA thresholds: 0.5% unauthorized, 3% administrative, 15% overall. Lightspark can suspend ACH access above them. Two things lower your return rate. Show the customer the amount and date of each debit before you submit it, so they recognize it on their statement and keep enough money in the account. Do not submit a pull that your own risk checks flag. Checking the account before you submit helps too: account-linking services can confirm the account and its balance, which cuts insufficient-funds returns, the most common kind. * **Fraud screening.** Screen pulls before you submit them, with baseline profiling of each customer's normal activity. Pulls attract fraud because the fraudster needs only the account details, not the victim's bank login. ## Sandbox Sandbox accounts do not go through validation. The last three digits of the external account number decide what a pull from it does. | Suffix | Behavior | | - | - | | `002` | Insufficient funds. The pull fails immediately. | | `003` | Account closed or invalid. The pull fails immediately. | | `004` | The bank rejects the debit. The pull fails immediately. | | `005` | Stays pending about 30 seconds, then fails. | | `009` | Settles and completes, then about 30 seconds later the bank returns it. Lightspark reverses the credit and the transaction moves to `FAILED` with `railDetails.status` set to `RETURNED`. | | Any other | The pull completes. | Use `009` to test a deposit that completed and then came back. # Depositing Funds Source: https://agents-docs.lightspark.com/payouts-and-b2b/depositing-funds/depositing-funds Depositing funds into internal accounts Grid provides two options to fund an account: * Prefund * Just-in-time funding With prefunding, you'll deposit funds into internal accounts via Wire, PIX, hosted payment page, or crypto transfers. You can then use the balances as the source of funds for quotes and transfers. With just-in-time funding, you'll receive payment instructions as part of the quote. Once funds arrive, the payment to the receiver is automatically initiated. > [!NOTE] > > Just-in-time funding supports instant payment rails only (for example: RTP, > PIX, SEPA Instant). ## Prerequisites * You have created a customer (for customer-scoped internal accounts) * You have `GRID_CLIENT_ID` and `GRID_CLIENT_SECRET` > [!TIP] > > Export your credentials for use with cURL: > > ```bash theme={null} > export GRID_CLIENT_ID="your_client_id" > export GRID_CLIENT_SECRET="your_client_secret" > ``` ## Prefunding an account via push payments (Wire, SEPA, PIX, etc.) When customers need to deposit funds themselves, display the funding payment instructions in your application: **1. Retrieve the internal account** Fetch the customer's internal account for their desired currency using the API. ```bash cURL (Customer accounts) theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ```bash cURL (Platform internal accounts) theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/platform/internal-accounts' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **2. Extract payment instructions** Parse the `fundingPaymentInstructions` array and select the appropriate instructions based on your customer's preferred payment method. ```javascript theme={null} const instructions = account.fundingPaymentInstructions[0]; const bankInfo = instructions.accountOrWalletInfo; ``` **3. Display instructions clearly** Show the payment details prominently in your UI and enable copy / paste: * Account holder name * Bank name and routing information (account/routing number, CLABE, PIX key, etc.) * Payment URL (for COP deposits, redirect the customer to the `paymentUrl` returned in the payment instructions) * Reference code (if provided) * Any additional notes from `instructionsNotes` **Step 4** Customer initiates a push payment from their bank or wallet to the account/address specified. **5. Monitor for deposits** Set up webhook listeners to receive updates for the deposit transaction and account balance updates. The account balance will update automatically. > [!NOTE] > > You'll receive `INTERNAL_ACCOUNT.BALANCE_UPDATED` webhook events when the internal account balance changes. ## Just-in-time funding (payment instructions from a quote) With just-in-time funding, you request a quote and receive payment instructions (for example, a bank account or instant rail details). When your customer confirms the transaction, you trigger payment from your app. More details of just-in-time funding can be found in the Sending Payments guides. # External Accounts Source: https://agents-docs.lightspark.com/payouts-and-b2b/depositing-funds/external-accounts Add and manage external bank accounts, wallets, and payment destinations for withdrawals and payouts External accounts are bank accounts, cryptocurrency wallets, or payment destinations outside Grid where you can send funds. Grid supports two types: * **Customer external accounts** - Scoped to individual customers, used for withdrawals and customer-specific payouts * **Platform external accounts** - Scoped to your platform, used for platform-wide operations like receiving funds from external sources > [!NOTE] > > Customer external accounts often require some basic beneficiary information for compliance. > Platform accounts are managed at the organization level. ## Look up bank names with the Discoveries API Some countries require a `bankName` when creating an external account — including the Philippines, Nigeria, South Africa, and others. The `bankName` value must match exactly what Grid expects. Use the [Discoveries API](/api-reference/discoveries/list-available-receiving-institution-names) to retrieve the list of valid bank names for a given country and currency: ```bash cURL theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/discoveries?country=PH¤cy=PHP' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ```json Response theme={null} { "data": [ { "bankName": "BDO Unibank", "displayName": "BDO Unibank", "country": "PH", "currency": "PHP" }, { "bankName": "BPI", "displayName": "Bank of the Philippine Islands", "country": "PH", "currency": "PHP" } ] } ``` Use `bankName` from the response as the `bankName` value when creating an external account. The `displayName` is a human-friendly label you can show to your users. You can filter the list on the client side as the user types to provide a bank search experience. ## Create external accounts by region or wallet > [tab] United States > > **ACH, Wire, RTP** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USD", > "platformAccountId": "user_123_primary_bank", > "accountInfo": { > "accountType": "USD_ACCOUNT", > "accountNumber": "123456789", > "routingNumber": "021000021", > "bankAccountType": "CHECKING", > "bankName": "Chase Bank", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "John Doe", > "birthDate": "1990-01-15", > "nationality": "US", > "address": { > "line1": "123 Main Street", > "city": "San Francisco", > "state": "CA", > "postalCode": "94105", > "country": "US" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankAccountType` is required and must be `CHECKING` or `SAVINGS`. Grid uses it to set the > > ACH transaction code, so a value that does not match the account causes the receiving bank > > to return a notification of change. `routingNumber` must be 9 digits. > > > [!NOTE] > > > > Three further fields apply to the `WIRE` rail and are ignored on ACH, RTP and FedNow: > > `intermediaryBankName` and `intermediaryRoutingNumber`, for a beneficiary bank reachable only > > through a correspondent, and `fiToFiInformation` for bank-to-bank instructions. > [tab] Mexico > > **CLABE/SPEI** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "MXN", > "platformAccountId": "mx_beneficiary_001", > "accountInfo": { > "accountType": "MXN_ACCOUNT", > "clabeNumber": "123456789012345678", > "bankName": "BBVA Mexico", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "María García", > "birthDate": "1985-03-15", > "nationality": "MX", > "address": { > "line1": "Av. Reforma 123", > "city": "Ciudad de México", > "state": "CDMX", > "postalCode": "06600", > "country": "MX" > } > } > } > }' > ``` > [tab] Brazil > > **PIX** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "BRL", > "platformAccountId": "br_pix_001", > "accountInfo": { > "accountType": "BRL_ACCOUNT", > "pixKey": "user@email.com", > "pixKeyType": "EMAIL", > "bankName": "Nubank", > "taxId": "12345678900", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "João Silva", > "birthDate": "1988-07-22", > "nationality": "BR", > "address": { > "line1": "Rua das Flores 456", > "city": "São Paulo", > "state": "SP", > "postalCode": "01234-567", > "country": "BR" > } > } > } > }' > ``` > > > [!TIP] > > > > Key types: `CPF`, `CNPJ`, `EMAIL`, `PHONE`, or `RANDOM` > [tab] Colombia > > **Bank Transfer or Mobile Money (Nequi, Daviplata)** > > Colombia supports two payment rails: bank transfer and mobile money. > > **Bank Transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "COP", > "platformAccountId": "co_bank_001", > "accountInfo": { > "accountType": "COP_ACCOUNT", > "bankName": "Bancolombia", > "accountNumber": "1234567890", > "bankAccountType": "CHECKING", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Juan Carlos García", > "birthDate": "1988-03-15", > "nationality": "CO", > "documentType": "CC", > "documentNumber": "1234567890", > "address": { > "line1": "Carrera 7 #45-23", > "city": "Bogotá", > "postalCode": "110111", > "country": "CO" > } > } > } > }' > ``` > > **Mobile Money (Nequi, Daviplata):** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "COP", > "platformAccountId": "co_mobile_001", > "accountInfo": { > "accountType": "COP_ACCOUNT", > "bankName": "Nequi", > "phoneNumber": "+573001234567", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "María López", > "birthDate": "1992-07-20", > "nationality": "CO", > "documentType": "CC", > "documentNumber": "9876543210", > "address": { > "line1": "Calle 100 #15-30", > "city": "Medellín", > "postalCode": "050021", > "country": "CO" > } > } > } > }' > ``` > > > [!NOTE] > > > > Bank transfer requires `bankName`, `accountNumber`, and `bankAccountType` (CHECKING or SAVINGS). > > Mobile money requires `phoneNumber` in international format (e.g., +573001234567). > > Most Colombian banks require `documentType` and `documentNumber` for KYC matching. > > Document types: `CC` (Cédula de Ciudadanía), `CE` (Cédula de Extranjería), `TI` (Tarjeta de Identidad), `NIT` (Número de Identificación Tributaria), `PP` (Passport). > [tab] El Salvador > > **Bank Transfer or Mobile Money (Tigo Money)** > > El Salvador supports two payment rails: bank transfer and mobile money. > > **Bank Transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USD", > "platformAccountId": "sv_bank_001", > "accountInfo": { > "accountType": "SLV_ACCOUNT", > "bankName": "Banco Cuscatlan", > "accountNumber": "0123456789", > "bankAccountType": "CHECKING", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Carlos Morales", > "birthDate": "1990-05-20", > "nationality": "SV", > "address": { > "line1": "Colonia Escalon 123", > "city": "San Salvador", > "postalCode": "01101", > "country": "SV" > } > } > } > }' > ``` > > **Mobile Money (Tigo Money):** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USD", > "platformAccountId": "sv_mobile_001", > "accountInfo": { > "accountType": "SLV_ACCOUNT", > "phoneNumber": "+50312345678", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Ana Martinez", > "birthDate": "1985-08-15", > "nationality": "SV", > "address": { > "line1": "Avenida Roosevelt 456", > "city": "San Salvador", > "postalCode": "01101", > "country": "SV" > } > } > } > }' > ``` > > > [!NOTE] > > > > Bank transfer requires `bankName`, `accountNumber`, and `bankAccountType` (CHECKING or SAVINGS). > > Mobile money requires `phoneNumber` in international format (e.g., +50312345678). > [tab] Europe > > **EUR Account (IBAN)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "EUR", > "platformAccountId": "eu_iban_001", > "accountInfo": { > "accountType": "EUR_ACCOUNT", > "iban": "DE89370400440532013000", > "bankName": "Deutsche Bank", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Hans Schmidt", > "birthDate": "1982-11-08", > "nationality": "DE", > "address": { > "line1": "Hauptstraße 789", > "city": "Berlin", > "state": "Berlin", > "postalCode": "10115", > "country": "DE" > } > } > } > }' > ``` > > When you omit `swiftCode`, Grid derives it from the IBAN when possible. Provide > it when automatic derivation is unavailable. > [tab] Philippines > > **PHP Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "PHP", > "platformAccountId": "ph_bank_001", > "accountInfo": { > "accountType": "PHP_ACCOUNT", > "bankName": "BDO Unibank", > "accountNumber": "001234567890", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Maria Santos", > "birthDate": "1995-04-10", > "nationality": "PH", > "address": { > "line1": "123 Rizal Avenue", > "city": "Manila", > "state": "Metro Manila", > "postalCode": "1000", > "country": "PH" > } > } > } > }' > ``` > > > [!NOTE] > > > > Account number must be 8-16 digits. > [tab] United Kingdom > > **GBP Faster Payments** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "GBP", > "platformAccountId": "gb_bank_001", > "accountInfo": { > "accountType": "GBP_ACCOUNT", > "sortCode": "123456", > "accountNumber": "12345678", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "James Smith", > "birthDate": "1985-09-03", > "nationality": "GB", > "address": { > "line1": "10 Downing Street", > "city": "London", > "postalCode": "SW1A 2AA", > "country": "GB" > } > } > } > }' > ``` > > > [!NOTE] > > > > Sort code must be 6 digits. Account number must be 8 digits. Address is required for GBP individual beneficiaries. > [tab] India > > **UPI, NEFT, or RTGS** > > India supports three payment rails: UPI (instant), NEFT (near-real-time), and RTGS (real-time, for large transfers). > > **UPI:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "INR", > "platformAccountId": "in_upi_001", > "accountInfo": { > "accountType": "INR_ACCOUNT", > "vpa": "user@okbank", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Priya Sharma", > "birthDate": "1991-05-14", > "nationality": "IN", > "address": { > "line1": "123 MG Road", > "city": "Mumbai", > "state": "Maharashtra", > "postalCode": "400001", > "country": "IN" > } > } > } > }' > ``` > > **NEFT / RTGS (bank transfer):** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "INR", > "platformAccountId": "in_neft_001", > "accountInfo": { > "accountType": "INR_ACCOUNT", > "accountNumber": "000111222333", > "ifsc": "HDFC0001234", > "rail": "NEFT", > "bankName": "HDFC Bank", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Priya Sharma", > "birthDate": "1991-05-14", > "nationality": "IN", > "address": { > "line1": "123 MG Road", > "city": "Mumbai", > "state": "Maharashtra", > "postalCode": "400001", > "country": "IN" > } > } > } > }' > ``` > > > [!NOTE] > > > > UPI requires `vpa`. NEFT and RTGS require `accountNumber` (9-18 digits), `ifsc` > > (11-character branch code), and `rail` (`NEFT` or `RTGS`). `bankName` is optional > > on both. Do not send `paymentRails` — Grid returns it on the created account. > [tab] Nigeria > > **NGN Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "NGN", > "platformAccountId": "ng_bank_001", > "accountInfo": { > "accountType": "NGN_ACCOUNT", > "accountNumber": "0123456789", > "bankName": "First Bank of Nigeria", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Chukwuemeka Okonkwo", > "birthDate": "1990-06-20", > "nationality": "NG", > "address": { > "line1": "15 Marina Street", > "city": "Lagos", > "state": "Lagos", > "postalCode": "100001", > "country": "NG" > } > } > } > }' > ``` > > > [!NOTE] > > > > Account number must be exactly 10 digits. > [tab] South Africa > > **ZAR Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "ZAR", > "platformAccountId": "za_bank_001", > "accountInfo": { > "accountType": "ZAR_ACCOUNT", > "accountNumber": "1234567890", > "bankName": "Standard Bank", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Thabo Mbeki", > "birthDate": "1985-04-12", > "nationality": "ZA", > "address": { > "line1": "42 Nelson Mandela Drive", > "city": "Johannesburg", > "state": "Gauteng", > "postalCode": "2000", > "country": "ZA" > } > } > } > }' > ``` > > > [!NOTE] > > > > Account number must be 9-13 digits. > [tab] Kenya > > **KES Mobile Money (M-PESA)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "KES", > "platformAccountId": "ke_mpesa_001", > "accountInfo": { > "accountType": "KES_ACCOUNT", > "phoneNumber": "+254712345678", > "provider": "M-PESA", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Wanjiku Kamau", > "birthDate": "1990-08-20", > "nationality": "KE", > "address": { > "line1": "Kenyatta Avenue 15", > "city": "Nairobi", > "postalCode": "00100", > "country": "KE" > } > } > } > }' > ``` > > > [!NOTE] > > > > Phone number must be in format `+254XXXXXXXXX` (9 digits after country code). Provider must be `M-PESA`. > [tab] Tanzania > > **TZS Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "TZS", > "platformAccountId": "tz_mobile_001", > "accountInfo": { > "accountType": "TZS_ACCOUNT", > "phoneNumber": "+255712345678", > "provider": "VODACOM", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Juma Hassan", > "birthDate": "1988-02-14", > "nationality": "TZ", > "address": { > "line1": "Samora Avenue 28", > "city": "Dar es Salaam", > "postalCode": "11101", > "country": "TZ" > } > } > } > }' > ``` > > > [!NOTE] > > > > Phone number must be in format `+255XXXXXXXXX` (9 digits after country code). Providers: `AIRTEL` or `VODACOM`. > [tab] Zambia > > **ZMW Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "ZMW", > "platformAccountId": "zm_mobile_001", > "accountInfo": { > "accountType": "ZMW_ACCOUNT", > "phoneNumber": "+260971234567", > "provider": "MTN", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Mwila Chanda", > "birthDate": "1992-11-05", > "nationality": "ZM", > "address": { > "line1": "Cairo Road 100", > "city": "Lusaka", > "postalCode": "10101", > "country": "ZM" > } > } > } > }' > ``` > > > [!NOTE] > > > > Phone number must be in format `+260XXXXXXXXX` (9 digits after country code). Providers: `TNM`, `AIRTEL`, `ZAMTEL`, or `MTN`. > [tab] Rwanda > > **RWF Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "RWF", > "platformAccountId": "rw_mobile_001", > "accountInfo": { > "accountType": "RWF_ACCOUNT", > "phoneNumber": "+250781234567", > "provider": "MTN", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Uwimana Diane", > "birthDate": "1995-06-30", > "nationality": "RW", > "address": { > "line1": "KN 5 Avenue", > "city": "Kigali", > "postalCode": "00100", > "country": "RW" > } > } > } > }' > ``` > > > [!NOTE] > > > > Phone number must be in format `+250XXXXXXXXX` (9 digits after country code). Providers: `MTN` or `AIRTEL`. > [tab] Malawi > > **MWK Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "MWK", > "platformAccountId": "mw_mobile_001", > "accountInfo": { > "accountType": "MWK_ACCOUNT", > "phoneNumber": "+265991234567", > "provider": "AIRTEL", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Chimwemwe Banda", > "birthDate": "1993-03-18", > "nationality": "MW", > "address": { > "line1": "Kamuzu Procession Road", > "city": "Lilongwe", > "postalCode": "00100", > "country": "MW" > } > } > } > }' > ``` > > > [!NOTE] > > > > Phone number must be in format `+265XXXXXXXXX` (9 digits after country code). Providers: `AIRTEL` or `TNM`. > [tab] Uganda > > **UGX Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "UGX", > "platformAccountId": "ug_mobile_001", > "accountInfo": { > "accountType": "UGX_ACCOUNT", > "phoneNumber": "+256701234567", > "provider": "MTN", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Nakato Sarah", > "birthDate": "1990-07-25", > "nationality": "UG", > "address": { > "line1": "Kampala Road 45", > "city": "Kampala", > "postalCode": "00100", > "country": "UG" > } > } > } > }' > ``` > > > [!NOTE] > > > > Phone number must be in format `+256XXXXXXXXX` (9 digits after country code). Providers: `MTN` or `AIRTEL`. > [tab] West Africa > > **XOF Mobile Money (Senegal, Benin, Ivory Coast)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "XOF", > "platformAccountId": "xof_mobile_001", > "accountInfo": { > "accountType": "XOF_ACCOUNT", > "phoneNumber": "+221781234567", > "provider": "ORANGE", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Mamadou Diallo", > "birthDate": "1988-11-12", > "nationality": "SN", > "address": { > "line1": "Avenue Cheikh Anta Diop", > "city": "Dakar", > "postalCode": "10000", > "country": "SN" > } > } > } > }' > ``` > > > [!NOTE] > > > > Supported countries and phone formats: > > > > * Senegal (+221): `+221XXXXXXXXX` (9 digits after country code) > > * Ivory Coast (+225): `+225XXXXXXXXXX` (10 digits after country code) > > * Benin (+229): `+229XXXXXXXX` or `+229XXXXXXXXX` (8–9 digits after country code) > [tab] Canada > > **CAD Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "CAD", > "platformAccountId": "ca_bank_001", > "accountInfo": { > "accountType": "CAD_ACCOUNT", > "bankCode": "001", > "branchCode": "00012", > "accountNumber": "1234567", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Emily Thompson", > "birthDate": "1988-09-12", > "nationality": "CA", > "address": { > "line1": "456 Queen Street West", > "city": "Toronto", > "state": "ON", > "postalCode": "M5V 2B3", > "country": "CA" > } > } > } > }' > ``` > > > [!NOTE] > > > > Bank code is 3 digits, branch code is 5 digits, account number is 7-12 digits. > [tab] United Arab Emirates > > **Bank Transfer (IBAN)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "AED", > "platformAccountId": "ae_bank_001", > "accountInfo": { > "accountType": "AED_ACCOUNT", > "iban": "AE070331234567890123456", > "swiftCode": "EBILAEAD", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Fatima Al Mansoori", > "birthDate": "1990-01-15", > "nationality": "AE", > "address": { > "line1": "Sheikh Zayed Road 12", > "city": "Dubai", > "postalCode": "00000", > "country": "AE" > } > } > } > }' > ``` > > > [!NOTE] > > > > `iban` is required and must be a 23-character UAE IBAN starting with `AE`. `swiftCode` is optional. An `address` is required on the beneficiary. > [tab] Israel > > **Bank Transfer (IBAN)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "ILS", > "platformAccountId": "il_bank_001", > "accountInfo": { > "accountType": "ILS_ACCOUNT", > "iban": "IL620108000000099999999", > "bankName": "Bank Leumi", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Noa Levi", > "birthDate": "1990-01-15", > "nationality": "IL", > "address": { > "line1": "Rothschild Boulevard 12", > "city": "Tel Aviv", > "postalCode": "6688112", > "country": "IL" > } > } > } > }' > ``` > > > [!NOTE] > > > > `iban` is required and must be a 23-character Israeli IBAN starting with `IL`. `bankName` is required. Business beneficiaries (`beneficiaryType: "BUSINESS"`) require `legalName` and `address`. > [tab] Bangladesh > > **Bank Transfer or Mobile Money (bKash, Nagad)** > > **Bank transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "BDT", > "platformAccountId": "bd_bank_001", > "accountInfo": { > "accountType": "BDT_ACCOUNT", > "bankName": "BRAC Bank", > "accountNumber": "1234567890123", > "branchCode": "12345", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Rahim Chowdhury", > "birthDate": "1990-01-15", > "nationality": "BD", > "address": { > "line1": "12 Gulshan Avenue", > "city": "Dhaka", > "postalCode": "1212", > "country": "BD" > } > } > } > }' > ``` > > **Mobile money:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "BDT", > "platformAccountId": "bd_mobile_001", > "accountInfo": { > "accountType": "BDT_ACCOUNT", > "bankName": "bKash", > "phoneNumber": "+8801712345678", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Rahim Chowdhury", > "birthDate": "1990-01-15", > "nationality": "BD", > "address": { > "line1": "12 Gulshan Avenue", > "city": "Dhaka", > "postalCode": "1212", > "country": "BD" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName` is always required. Bank transfer additionally uses `accountNumber` and optionally `branchCode`; mobile money uses `phoneNumber`. `swiftCode` is optional on bank transfers. > [tab] Botswana > > **Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "BWP", > "platformAccountId": "bw_mobile_001", > "accountInfo": { > "accountType": "BWP_ACCOUNT", > "phoneNumber": "+26771234567", > "provider": "Orange", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Kagiso Molefe", > "birthDate": "1990-01-15", > "nationality": "BW", > "address": { > "line1": "Plot 123 Main Mall", > "city": "Gaborone", > "postalCode": "00000", > "country": "BW" > } > } > } > }' > ``` > > > [!NOTE] > > > > Both `phoneNumber` (international format) and `provider` are required. > [tab] China > > **AliPay / WeChat Pay or Bank Transfer** > > **Bank transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "CNY", > "platformAccountId": "cn_bank_001", > "accountInfo": { > "accountType": "CNY_ACCOUNT", > "bankName": "Industrial and Commercial Bank of China", > "accountNumber": "6217000010000000001", > "beneficiary": { > "beneficiaryType": "BUSINESS", > "legalName": "Shanghai Trading Co Ltd", > "registrationNumber": "91110000000000000X", > "address": { > "line1": "1 Nanjing Road", > "city": "Shanghai", > "postalCode": "200000", > "country": "CN" > } > } > } > }' > ``` > > **AliPay / WeChat Pay:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "CNY", > "platformAccountId": "cn_mobile_001", > "accountInfo": { > "accountType": "CNY_ACCOUNT", > "bankName": "AliPay", > "phoneNumber": "+8613812345678", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Wei Zhang", > "birthDate": "1990-01-15", > "nationality": "CN", > "address": { > "line1": "1 Nanjing Road", > "city": "Shanghai", > "postalCode": "200000", > "country": "CN" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName` is always required. For a wallet payout it selects the wallet and must be > > exactly `AliPay` or `WechatPay`, paired with `phoneNumber`. Bank transfer uses > > `accountNumber` with the beneficiary bank's name, and is business-to-business only — > > send `beneficiaryType: "BUSINESS"`. A bank transfer payout also needs supporting > > documents. See [Supporting > > documents](/payouts-and-b2b/payment-flow/send-payment#supporting-documents). > [tab] Egypt > > **Bank Transfer or Mobile Money** > > **Bank transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "EGP", > "platformAccountId": "eg_bank_001", > "accountInfo": { > "accountType": "EGP_ACCOUNT", > "bankName": "National Bank of Egypt", > "iban": "EG380019000500000000263180002", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Nour Hassan", > "birthDate": "1990-01-15", > "nationality": "EG", > "address": { > "line1": "15 Tahrir Square", > "city": "Cairo", > "postalCode": "11511", > "country": "EG" > } > } > } > }' > ``` > > **Mobile money:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "EGP", > "platformAccountId": "eg_mobile_001", > "accountInfo": { > "accountType": "EGP_ACCOUNT", > "bankName": "Vodafone Cash", > "phoneNumber": "+201012345678", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Nour Hassan", > "birthDate": "1990-01-15", > "nationality": "EG", > "address": { > "line1": "15 Tahrir Square", > "city": "Cairo", > "postalCode": "11511", > "country": "EG" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName` is always required. Bank transfer uses `iban`, a 29-character Egyptian IBAN starting with `EG`; mobile money uses `phoneNumber`. > [tab] Ghana > > **Bank Transfer or Mobile Money (MTN, Vodafone)** > > **Bank transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "GHS", > "platformAccountId": "gh_bank_001", > "accountInfo": { > "accountType": "GHS_ACCOUNT", > "bankName": "GCB Bank", > "accountNumber": "1234567890", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Kwame Mensah", > "birthDate": "1990-01-15", > "nationality": "GH", > "address": { > "line1": "23 Independence Avenue", > "city": "Accra", > "postalCode": "00233", > "country": "GH" > } > } > } > }' > ``` > > **Mobile money:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "GHS", > "platformAccountId": "gh_mobile_001", > "accountInfo": { > "accountType": "GHS_ACCOUNT", > "bankName": "MTN Mobile Money", > "phoneNumber": "+233241234567", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Kwame Mensah", > "birthDate": "1990-01-15", > "nationality": "GH", > "address": { > "line1": "23 Independence Avenue", > "city": "Accra", > "postalCode": "00233", > "country": "GH" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName` is always required. Bank transfer uses `accountNumber`; mobile money uses `phoneNumber`. > [tab] Guatemala > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "GTQ", > "platformAccountId": "gt_bank_001", > "accountInfo": { > "accountType": "GTQ_ACCOUNT", > "accountNumber": "1234567890", > "bankAccountType": "CHECKING", > "bankName": "Banco GYT Continental", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Ana Lucía Ramírez", > "birthDate": "1990-01-15", > "nationality": "GT", > "phoneNumber": "+50251234567", > "countryOfResidence": "GT", > "address": { > "line1": "5a Avenida 12-34 Zona 10", > "city": "Guatemala City", > "postalCode": "01010", > "country": "GT" > } > } > } > }' > ``` > > > [!NOTE] > > > > `accountNumber`, `bankAccountType` (`CHECKING` or `SAVINGS`) and `bankName` are all required. The beneficiary additionally requires `phoneNumber` and `countryOfResidence`. > [tab] Haiti > > **Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "HTG", > "platformAccountId": "ht_mobile_001", > "accountInfo": { > "accountType": "HTG_ACCOUNT", > "phoneNumber": "+50934567890", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Marie Pierre-Louis", > "birthDate": "1990-01-15", > "nationality": "HT", > "address": { > "line1": "45 Rue Capois", > "city": "Port-au-Prince", > "postalCode": "6110", > "country": "HT" > } > } > } > }' > ``` > > > [!NOTE] > > > > `phoneNumber` in international format is the only required account field. > [tab] Jamaica > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "JMD", > "platformAccountId": "jm_bank_001", > "accountInfo": { > "accountType": "JMD_ACCOUNT", > "accountNumber": "1234567890", > "branchCode": "12345", > "bankAccountType": "CHECKING", > "bankName": "Bank of Nova Scotia", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Andre Campbell", > "birthDate": "1990-01-15", > "nationality": "JM", > "phoneNumber": "+18761234567", > "address": { > "line1": "10 Knutsford Boulevard", > "city": "Kingston", > "postalCode": "00000", > "country": "JM" > } > } > } > }' > ``` > > > [!NOTE] > > > > `accountNumber`, `branchCode` (5 digits), `bankAccountType` (`CHECKING` or `SAVINGS`) and `bankName` are all required. The beneficiary additionally requires `phoneNumber` and an `address`. > [tab] Pakistan > > **Bank Transfer or Mobile Money (JazzCash, Easypaisa)** > > **Bank transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "PKR", > "platformAccountId": "pk_bank_001", > "accountInfo": { > "accountType": "PKR_ACCOUNT", > "bankName": "HBL", > "accountNumber": "1234567890123456", > "iban": "PK36SCBL0000001123456702", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Ayesha Khan", > "birthDate": "1990-01-15", > "nationality": "PK", > "address": { > "line1": "12 Clifton Block 5", > "city": "Karachi", > "postalCode": "75600", > "country": "PK" > } > } > } > }' > ``` > > **Mobile money:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "PKR", > "platformAccountId": "pk_mobile_001", > "accountInfo": { > "accountType": "PKR_ACCOUNT", > "bankName": "JazzCash", > "phoneNumber": "+923001234567", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Ayesha Khan", > "birthDate": "1990-01-15", > "nationality": "PK", > "address": { > "line1": "12 Clifton Block 5", > "city": "Karachi", > "postalCode": "75600", > "country": "PK" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName` is always required. Bank transfer uses `accountNumber`, and optionally `iban` (24 characters, starting with `PK`); mobile money uses `phoneNumber`. > [tab] Central Africa > > **Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "XAF", > "platformAccountId": "xaf_mobile_001", > "accountInfo": { > "accountType": "XAF_ACCOUNT", > "phoneNumber": "+237671234567", > "provider": "MTN", > "region": "CM", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Achille Nkeng", > "birthDate": "1990-01-15", > "nationality": "CM", > "address": { > "line1": "Rue Joss 20", > "city": "Douala", > "postalCode": "00237", > "country": "CM" > } > } > } > }' > ``` > > > [!NOTE] > > > > `phoneNumber`, `provider` and `region` are all required. `region` must be `CM` (Cameroon) or `CG` (Republic of the Congo). > [tab] Denmark > > **Bank Transfer (IBAN)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "DKK", > "platformAccountId": "dk_bank_001", > "accountInfo": { > "accountType": "DKK_ACCOUNT", > "iban": "DK5000400040116243", > "swiftCode": "DABADKKK", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Lars Jensen", > "birthDate": "1990-01-15", > "nationality": "DK", > "address": { > "line1": "Vesterbrogade 10", > "city": "Copenhagen", > "postalCode": "1620", > "country": "DK" > } > } > } > }' > ``` > > > [!NOTE] > > > > `iban` is required; `swiftCode` is optional. > [tab] Hong Kong > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "HKD", > "platformAccountId": "hk_bank_001", > "accountInfo": { > "accountType": "HKD_ACCOUNT", > "bankName": "HSBC Hong Kong", > "accountNumber": "123456789012", > "swiftCode": "HSBCHKHHHKH", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Emily Chan", > "birthDate": "1990-01-15", > "nationality": "HK", > "address": { > "line1": "1 Queen's Road Central", > "city": "Hong Kong", > "postalCode": "00000", > "country": "HK" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName`, `accountNumber` and `swiftCode` are all required. > [tab] Indonesia > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "IDR", > "platformAccountId": "id_bank_001", > "accountInfo": { > "accountType": "IDR_ACCOUNT", > "bankName": "Bank Central Asia", > "accountNumber": "1234567890", > "swiftCode": "CENAIDJA", > "phoneNumber": "+6281234567890", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Siti Rahayu", > "birthDate": "1990-01-15", > "nationality": "ID", > "address": { > "line1": "Jl. Sudirman 45", > "city": "Jakarta", > "postalCode": "10210", > "country": "ID" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName`, `accountNumber`, `swiftCode` and `phoneNumber` are all required. > [tab] Malaysia > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "MYR", > "platformAccountId": "my_bank_001", > "accountInfo": { > "accountType": "MYR_ACCOUNT", > "bankName": "Maybank", > "accountNumber": "1234567890", > "swiftCode": "MABORUMMYYY", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Nurul Aisyah", > "birthDate": "1990-01-15", > "nationality": "MY", > "address": { > "line1": "Jalan Ampang 88", > "city": "Kuala Lumpur", > "postalCode": "50450", > "country": "MY" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName`, `accountNumber` and `swiftCode` are all required. > [tab] Singapore > > **Bank Transfer (PayNow, FAST)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "SGD", > "platformAccountId": "sg_bank_001", > "accountInfo": { > "accountType": "SGD_ACCOUNT", > "bankName": "DBS Bank Ltd", > "accountNumber": "0123456789", > "swiftCode": "DBSSSGSG", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Wei Lin Tan", > "birthDate": "1990-01-15", > "nationality": "SG", > "address": { > "line1": "12 Marina Boulevard", > "city": "Singapore", > "postalCode": "018982", > "country": "SG" > } > } > } > }' > ``` > > > [!NOTE] > > > > `accountNumber` and `swiftCode` are required; `bankName` is optional. > [tab] Thailand > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "THB", > "platformAccountId": "th_bank_001", > "accountInfo": { > "accountType": "THB_ACCOUNT", > "bankName": "Bangkok Bank", > "accountNumber": "1234567890", > "swiftCode": "BKKBTHBK", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Somchai Prasert", > "birthDate": "1990-01-15", > "nationality": "TH", > "address": { > "line1": "333 Silom Road", > "city": "Bangkok", > "postalCode": "10500", > "country": "TH" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName`, `accountNumber` and `swiftCode` are all required. > [tab] Vietnam > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "VND", > "platformAccountId": "vn_bank_001", > "accountInfo": { > "accountType": "VND_ACCOUNT", > "bankName": "Vietcombank", > "accountNumber": "1234567890", > "swiftCode": "BFTVVNVX", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Nguyen Thi Lan", > "birthDate": "1990-01-15", > "nationality": "VN", > "address": { > "line1": "22 Le Loi", > "city": "Ho Chi Minh City", > "postalCode": "70000", > "country": "VN" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName`, `accountNumber` and `swiftCode` are all required. > [tab] SWIFT (International) > > **SWIFT / International Wire** > > Use a `SWIFT_ACCOUNT` external account to send payouts to bank accounts in countries or currencies where Grid doesn't offer a local payment rail. Provide the bank's SWIFT/BIC code and the beneficiary's account number (or IBAN for IBAN-based corridors). > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USD", > "platformAccountId": "swift_usd_001", > "accountInfo": { > "accountType": "SWIFT_ACCOUNT", > "country": "NG", > "swiftCode": "GTBINGLA", > "bankName": "Guaranty Trust Bank", > "accountNumber": "1234567890", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Adaeze Okafor", > "birthDate": "1991-03-22", > "nationality": "NG", > "address": { > "line1": "15 Broad Street", > "city": "Lagos", > "state": "Lagos", > "postalCode": "101233", > "country": "NG" > } > } > } > }' > ``` > > > [!NOTE] > > > > `currency` is the currency the destination account holds, and `country` is where the bank > > account is located — they can differ. Provide `accountNumber` for most corridors; use `iban` > > instead for IBAN-only corridors (e.g. GB, BR). > [tab] Cryptocurrency > > **Bitcoin Lightning (Spark Wallet)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "BTC", > "platformAccountId": "btc_spark_001", > "accountInfo": { > "accountType": "SPARK_WALLET", > "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu" > } > }' > ``` > > **Ethereum L1** > > Supported assets: USDC and USDT. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDC", > "platformAccountId": "eth_usdc_001", > "accountInfo": { > "accountType": "ETHEREUM_WALLET", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > }' > ``` > > **Base** > > Supported assets: USDC. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDC", > "platformAccountId": "base_usdc_001", > "accountInfo": { > "accountType": "BASE_WALLET", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > }' > ``` > > **Polygon** > > Supported assets: USDC. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDC", > "platformAccountId": "polygon_usdc_001", > "accountInfo": { > "accountType": "POLYGON_WALLET", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > }' > ``` > > **Solana** > > Supported assets: USDC. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDC", > "platformAccountId": "sol_usdc_001", > "accountInfo": { > "accountType": "SOLANA_WALLET", > "address": "4Nd1m6Qkq7RfKuE5vQ9qP9Tn6H94Ueqb4xXHzsAbd8Wg" > } > }' > ``` > > **Tron** > > Supported assets: USDT. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDT", > "platformAccountId": "tron_usdt_001", > "accountInfo": { > "accountType": "TRON_WALLET", > "address": "TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL" > } > }' > ``` > > **Plasma** > > Supported assets: USDT. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDT", > "platformAccountId": "plasma_usdt_001", > "accountInfo": { > "accountType": "PLASMA_WALLET", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > }' > ``` > > **Arbitrum** > > Supported assets: USDC, USDT. > > ```bash cURL USDC 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDC", > "platformAccountId": "arbitrum_usdc_001", > "accountInfo": { > "accountType": "ARBITRUM_WALLET", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > }' > ``` > > ```bash cURL USDT 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDT", > "platformAccountId": "arbitrum_usdt_001", > "accountInfo": { > "accountType": "ARBITRUM_WALLET", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > }' > ``` > > **Lightning** > > Exactly one of `lightningAddress`, `invoice` (a single-use BOLT11 invoice) or `bolt12` > (a reusable offer) must be provided. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "BTC", > "platformAccountId": "btc_lightning_001", > "accountInfo": { > "accountType": "LIGHTNING", > "lightningAddress": "john.doe@lightningwallet.com" > } > }' > ``` > > > [!NOTE] > > > > Crypto wallets don't require beneficiary information. Send only assets on the > > named network — EVM addresses look identical across Ethereum, Base, Polygon, > > Plasma, and Arbitrum, and funds sent on the wrong network are unrecoverable. > [!TIP] > > Use `platformAccountId` to tie your internal id with the external account. **Sample Response:** ```json theme={null} { "id": "ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "status": "ACTIVE", "currency": "USD", "platformAccountId": "user_123_primary_bank", "accountInfo": { "accountType": "USD_ACCOUNT", "accountNumber": "123456789", "routingNumber": "021000021", "bankAccountType": "CHECKING", "bankName": "Chase Bank", "beneficiary": { "beneficiaryType": "INDIVIDUAL", "fullName": "John Doe", "birthDate": "1990-01-15", "nationality": "US", "address": { "line1": "123 Main Street", "city": "San Francisco", "state": "CA", "postalCode": "94105", "country": "US" } } } } ``` ### Business beneficiaries For business accounts, include business information: ```json theme={null} { "currency": "USD", "platformAccountId": "acme_corp_account", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "accountInfo": { "accountType": "USD_ACCOUNT", "accountNumber": "987654321", "routingNumber": "021000021", "bankAccountType": "CHECKING", "bankName": "Chase Bank", "beneficiary": { "beneficiaryType": "BUSINESS", "businessInfo": { "legalName": "Acme Corporation, Inc.", "taxId": "EIN-987654321" }, "address": { "line1": "456 Business Ave", "city": "New York", "state": "NY", "postalCode": "10001", "country": "US" } } } } ``` ## Minimum required beneficiary fields The following tables show the minimum required fields for individual and business beneficiaries. All other fields are optional but recommended for faster compliance review. ### Individual beneficiaries | Country | Required Fields | Required Address Fields | | - | - | - | | US (USD) | `beneficiaryType`, `fullName` | `line1`, `city`, `postalCode`, `country` | | Mexico (MXN) | `beneficiaryType`, `fullName` | None (address optional) | | Brazil (BRL) | `beneficiaryType`, `fullName` | None (address optional) | | Philippines (PHP) | `beneficiaryType`, `fullName` | None (address optional) | | United Kingdom (GBP) | `beneficiaryType`, `fullName`, `address` | `line1`, `city`, `postalCode`, `country` | | Europe (EUR) | `beneficiaryType`, `fullName`, `address` | `line1`, `city`, `postalCode`, `country` | > [!NOTE] > > While only the fields listed above are strictly required, providing additional information like `birthDate`, `nationality`, and `address` can reduce the likelihood of false positive compliance checks and increase transaction success rates. ### Business beneficiaries For business beneficiaries, the required fields vary by destination currency: | Currency | Required fields | | - | - | | USD | `legalName`, `address` | | MXN | `legalName`, `address` | | EUR | `legalName`, `address`, `registrationNumber` | | GBP, INR, BRL, DKK, PHP, HKD, IDR, MYR, SGD, THB, VND, AED, ILS | `legalName`, `address` | | NGN, ZAR, KES, TZS, RWF, ZMW, UGX, BWP, MWK, XOF, XAF | `legalName`, `address`, `registrationNumber`, `taxId` | > [!NOTE] > > When sending to GBP, INR, BRL, DKK, PHP, HKD, IDR, MYR, SGD, THB, VND, AED, or ILS, business senders (originators) must also provide `registrationNumber`. > [!NOTE] > > `registrationNumber` is the business's official registration or incorporation number (e.g., EIN in the US, CNPJ in Brazil, company registration number in the UK). `taxId` is the business's tax identification number where it differs from the registration number. ## Account status Beneficiary data may be reviewed for risk and compliance. Only `ACTIVE` accounts can receive payments. Updates to account data may trigger account re-review. | Status | Description | | - | - | | `PENDING` | Created, awaiting verification | | `ACTIVE` | Verified and ready for transactions | | `UNDER_REVIEW` | Additional review required | | `INACTIVE` | Disabled, cannot be used | ## Listing external accounts ### List customer accounts ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/external-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ### List platform accounts For platform-wide operations, list all platform-level external accounts: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/platform/external-accounts' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [!NOTE] > > Platform external accounts are used for platform-wide operations like > depositing funds from external sources. ## Retrieving a single external account Fetch one external account by its system-generated ID: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/external-accounts/ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response:** ```json theme={null} { "id": "ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "status": "ACTIVE", "currency": "USD", "platformAccountId": "acc_123456789", "accountInfo": { "accountType": "USD_ACCOUNT", "accountNumber": "1234567890", "routingNumber": "021000021", "bankName": "Chase Bank", "bankAccountType": "CHECKING", "paymentRails": ["ACH", "WIRE", "RTP", "FEDNOW"], "beneficiary": { "beneficiaryType": "INDIVIDUAL", "fullName": "Jane Doe" } } } ``` > [!NOTE] > > `paymentRails` reports the rails Grid selected for the account. It is > returned on the account and is not something you send when creating one. Returns `404` if no external account matches the ID. ## Deleting an external account Delete an external account by its system-generated ID: ```bash theme={null} curl -X DELETE 'https://api.lightspark.com/grid/2025-10-13/customers/external-accounts/ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Returns `204 No Content` on success. > [!WARNING] > > An account that is currently a **trusted beneficiary** for Strong Customer > Authentication cannot be deleted — the request fails with `409` and code > `BENEFICIARY_TRUSTED`. Untrust it first via `POST > /customers/external-accounts/{externalAccountId}/untrust` and its > `/confirm`, then delete. ## Best practices > [accordion] Validate account information > > Validate account details before submission: > > ```javascript theme={null} > // US accounts: 9-digit routing, 4-17 digit account number > if (!/^\d{9}$/.test(routingNumber)) { > throw new Error("Invalid routing number"); > } > > // CLABE: exactly 18 digits > if (!/^\d{18}$/.test(clabeNumber)) { > throw new Error("Invalid CLABE number"); > } > > // NGN: exactly 10-digit account number > if (!/^\d{10}$/.test(ngnAccountNumber)) { > throw new Error("Invalid Nigerian account number"); > } > > // CAD: 3-digit bank code, 5-digit branch code, 7-12 digit account number > if (!/^\d{3}$/.test(bankCode)) { > throw new Error("Invalid bank code"); > } > if (!/^\d{5}$/.test(branchCode)) { > throw new Error("Invalid branch code"); > } > if (!/^\d{7,12}$/.test(cadAccountNumber)) { > throw new Error("Invalid Canadian account number"); > } > > // PHP: 8-16 digit account number > if (!/^\d{8,16}$/.test(phpAccountNumber)) { > throw new Error("Invalid Philippine account number"); > } > > // GBP: 6-digit sort code, 8-digit account number > if (!/^\d{6}$/.test(sortCode)) { > throw new Error("Invalid sort code"); > } > if (!/^\d{8}$/.test(gbpAccountNumber)) { > throw new Error("Invalid UK account number"); > } > > // ZAR: 9-13 digit account number > if (!/^\d{9,13}$/.test(zarAccountNumber)) { > throw new Error("Invalid South African account number"); > } > > // African mobile money phone numbers > if (!/^\+254\d{9}$/.test(kesPhoneNumber)) { > throw new Error("Invalid Kenyan phone number"); > } > if (!/^\+255\d{9}$/.test(tzsPhoneNumber)) { > throw new Error("Invalid Tanzanian phone number"); > } > if (!/^\+260\d{9}$/.test(zmwPhoneNumber)) { > throw new Error("Invalid Zambian phone number"); > } > if (!/^\+250\d{9}$/.test(rwfPhoneNumber)) { > throw new Error("Invalid Rwandan phone number"); > } > if (!/^\+265\d{9}$/.test(mwkPhoneNumber)) { > throw new Error("Invalid Malawian phone number"); > } > if (!/^\+256\d{9}$/.test(ugxPhoneNumber)) { > throw new Error("Invalid Ugandan phone number"); > } > // West African (XOF): Senegal (+221), Ivory Coast (+225), Benin (+229) > if (!/^\+(221\d{9}|225\d{10}|229\d{8,9})$/.test(xofPhoneNumber)) { > throw new Error("Invalid West African phone number"); > } > ``` > [accordion] Check account status > > Verify status before sending payments: > > ```javascript theme={null} > if (account.status !== "ACTIVE") { > throw new Error(`Account is ${account.status}, cannot process payment`); > } > ``` > [accordion] Secure account data > > Never expose full account numbers. Display only masked info: > > ```javascript theme={null} > function displaySafely(account) { > return { > id: account.id, > bankName: account.accountInfo.bankName, > lastFour: account.accountInfo.accountNumber.slice(-4), > status: account.status, > }; > } > ``` ## Next steps ![](/images/icons/globe.svg) Cross-currency transfers Learn how to send international payments using external accounts [Open](/payouts-and-b2b/quickstart) ![](/images/icons/code.svg) API Reference View complete API documentation for external accounts [Open](/api-reference) # Internal Accounts Source: https://agents-docs.lightspark.com/payouts-and-b2b/depositing-funds/internal-accounts Learn how to manage internal accounts for holding platform and customer funds Internal accounts are Lightspark managed accounts that hold funds within the Grid platform. They allow you to receive deposits and send payments to external bank accounts or other payment destinations. They are useful for holding funds on behalf of the platform or customers which will be used for instant, 24/7 quotes and transfers out of the system. Internal accounts are created for both: * **Platform-level accounts**: Hold pooled funds for your platform operations (rewards distribution, reconciliation, etc.) * **Customer accounts**: Hold individual customer funds for their transactions > [!NOTE] > > Internal accounts are automatically created when you onboard a customer, based > on your platform's currency configuration. Platform-level internal accounts > are created when you configure your platform with supported currencies. > [!WARNING] > > On unregulated platforms, a customer's internal accounts aren't provisioned > until you record their End User Terms acceptance in `agreementConsents`, and > customer-scoped transactions are rejected with `403 > END_USER_TERMS_NOT_ACCEPTED` until then. See > [Disclosures](/payouts-and-b2b/onboarding/disclosures). ## How internal accounts work Internal accounts act as an intermediary holding account in the payment flow: 1. **Deposit funds**: You or your customers deposit money into internal accounts using bank transfers (ACH, wire, PIX, etc.) or crypto transfers 2. **Hold balance**: Funds are held securely in the internal account until needed 3. **Send payments**: You initiate transfers from internal accounts to external destinations Each internal account: * Is denominated in a single currency (USD, EUR, etc.) * Has a unique balance that you can query at any time * Includes unique payment instructions for depositing funds * Supports multiple funding methods depending on the currency ## Retrieving internal accounts ### List customer internal accounts To retrieve all internal accounts for a specific customer, use the customer ID to filter the results: ```bash Request internal accounts for a customer theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [accordion] Response > > ```json theme={null} > { > "data": [ > { > "id": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "type": "INTERNAL_FIAT", > "status": "ACTIVE", > "balance": { > "amount": 50000, > "currency": { > "code": "USD", > "name": "United States Dollar", > "symbol": "$", > "decimals": 2 > } > }, > "totalBalance": { > "amount": 50000, > "currency": { > "code": "USD", > "name": "United States Dollar", > "symbol": "$", > "decimals": 2 > } > }, > "fundingPaymentInstructions": [ > { > "accountOrWalletInfo": { > "reference": "FUND-ABC123", > "accountType": "USD_ACCOUNT", > "accountNumber": "9876543210", > "routingNumber": "021000021", > "bankName": "JP Morgan Chase", > "bankAddress": "270 Park Avenue, New York, NY 10017" > } > }, > { > "accountOrWalletInfo": { > "accountType": "SOLANA_WALLET", > "assetType": "USDC", > "address": "4Nd1m6Qkq7RfKuE5vQ9qP9Tn6H94Ueqb4xXHzsAbd8Wg" > } > } > ], > "createdAt": "2025-10-03T12:00:00Z", > "updatedAt": "2025-10-03T14:30:00Z" > } > ], > "hasMore": false, > "totalCount": 1 > } > ``` ### Filter by currency You can filter internal accounts by currency to find accounts for specific denominations: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001¤cy=USD' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ### List platform internal accounts To retrieve platform-level internal accounts (not tied to individual customers), use the platform internal accounts endpoint: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/platform/internal-accounts' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [!NOTE] > > Platform internal accounts are useful for managing pooled funds, distributing > rewards, or handling platform-level operations. ## Understanding funding payment instructions Each internal account includes `fundingPaymentInstructions` that tell your customers how to deposit funds. The structure varies by payment rail and currency: > [accordion] US ACH or wire transfers > > For USD accounts, instructions include routing and account numbers: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "USD_ACCOUNT", > "reference": "FUND-ABC123", > "accountNumber": "9876543210", > "routingNumber": "021000021", > "bankName": "JP Morgan Chase", > "bankAddress": "270 Park Avenue, New York, NY 10017" > } > } > ``` > > > [!TIP] > > > > Each internal account has its own account number, and deposits are > > credited by account number. The payer doesn't need to include the > > `reference`. A deposit sent without it is still credited to this account. > > The account is held in the account owner's own legal name: the customer's > `fullName` for an individual, or `businessInfo.legalName` for a business. > A platform internal account is held in your platform's legal name. Show > that name to payers as the beneficiary name, and ask them to copy it > exactly on wires. > [accordion] EUR account funding (SEPA) > > A `EUR_ACCOUNT` returns an IBAN, and deposits reach it over SEPA: > > ```json theme={null} > { > "instructionsNotes": "Include reference in SEPA transfer description", > "accountOrWalletInfo": { > "accountType": "EUR_ACCOUNT", > "reference": "FUND-EUR789", > "iban": "DE89370400440532013000", > "swiftCode": "DEUTDEFF", > "bankName": "Deutsche Bank" > } > } > ``` > [accordion] Spark Stablecoin Funding > > For stablecoin accounts, using a Spark wallet as the funding source: > > ```json theme={null} > { > "invoice": "lnbc15u1p3xnhl2pp5jptserfk3zk4qy42tlucycrfwxhydvlemu9pqr93tuzlv9cc7g3sdqsvfhkcap3xyhx7un8cqzpgxqzjcsp5f8c52y2stc300gl6s4xswtjpc37hrnnr3c9wvtgjfuvqmpm35evq9qyyssqy4lgd8tj637qcjp05rdpxxykjenthxftej7a2zzmwrmrl70fyj9hvj0rewhzj7jfyuwkwcg9g2jpwtk3wkjtwnkdks84hsnu8xps5vsq4gj5hs", > "instructionsNotes": "Use the invoice when making Spark payment", > "accountOrWalletInfo": { > "accountType": "SPARK_WALLET", > "assetType": "USDB", > "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu" > } > } > ``` > [accordion] Solana Stablecoin Funding > > For Solana wallet accounts, using a Solana wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "SOLANA_WALLET", > "assetType": "USDC", > "address": "4Nd1m6Qkq7RfKuE5vQ9qP9Tn6H94Ueqb4xXHzsAbd8Wg" > } > } > ``` > [accordion] Ethereum Stablecoin Funding > > For Ethereum L1 wallet accounts (USDC or USDT), using an Ethereum wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "ETHEREUM_WALLET", > "assetType": "USDT", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` > [accordion] Tron Stablecoin Funding > > For Tron wallet accounts, using a Tron wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "TRON_WALLET", > "assetType": "USDT", > "address": "TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL" > } > } > ``` > [accordion] Plasma Stablecoin Funding > > For Plasma wallet accounts, using a Plasma wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "PLASMA_WALLET", > "assetType": "USDT", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` > [accordion] Arbitrum Stablecoin Funding > > For Arbitrum wallet accounts, using an Arbitrum wallet as the funding source (USDC or USDT): > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "ARBITRUM_WALLET", > "assetType": "USDC", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` > [accordion] Polygon Stablecoin Funding > > For Polygon wallet accounts, using a Polygon wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "POLYGON_WALLET", > "assetType": "USDC", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` > [accordion] Base Stablecoin Funding > > For Base wallet accounts, using a Base wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "BASE_WALLET", > "assetType": "USDC", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` ## Checking account balances The internal account balance reflects all deposits and withdrawals. The balance includes: * **amount**: The balance amount in the smallest currency unit (cents for USD, centavos for MXN/BRL, etc.) * **currency**: Full currency details including code, name, symbol, and decimal places ### Example balance check ```bash Fetch the balance of an internal account theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001¤cy=USD' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [accordion] Response > > ```json theme={null} > { > "data": [ > { > "id": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "type": "INTERNAL_FIAT", > "status": "ACTIVE", > "balance": { > "amount": 50000, > "currency": { > "code": "USD", > "name": "United States Dollar", > "symbol": "$", > "decimals": 2 > } > }, > "totalBalance": { > "amount": 50000, > "currency": { > "code": "USD", > "name": "United States Dollar", > "symbol": "$", > "decimals": 2 > } > } > } > ], > "hasMore": false, > "totalCount": 1 > } > ``` > [!TIP] > > Always check the `decimals` field in the currency object to correctly convert > between display amounts and API amounts. For example, USD has 2 decimals, so > an amount of 50000 represents \$500.00. ## Displaying funding instructions to customers When customers need to deposit funds themselves, display the funding payment instructions in your application: **1. Retrieve the internal account** Fetch the customer's internal account for their desired currency using the API. **2. Extract payment instructions** Parse the `fundingPaymentInstructions` array and select the appropriate instructions based on your customer's preferred payment method. ```javascript theme={null} const instructions = account.fundingPaymentInstructions[0]; const bankInfo = instructions.accountOrWalletInfo; ``` **3. Display instructions clearly** Show the payment details prominently in your UI: * Account holder name * Bank name and routing information (account/routing number, CLABE, PIX key, etc.) * Reference code (if provided) * Any additional notes from `instructionsNotes` > [!TIP] > > The unique banking details in each internal account automatically route > deposits to the correct destination. **4. Monitor for deposits** Set up webhook listeners to receive notifications when deposits are credited to the internal account. The account balance will update automatically. > [!NOTE] > > You'll receive `INTERNAL_ACCOUNT.BALANCE_UPDATED` webhook events when the internal account balance changes. ## Best practices > [accordion] Display complete funding instructions > > Ensure your customers have all the information needed to make deposits. Consider implementing: > > * Clear display of all banking details from `fundingPaymentInstructions` > * Copy-to-clipboard functionality for account numbers and reference codes > * Email/SMS confirmations with complete deposit instructions > [accordion] Monitor account balances proactively > > Set up monitoring to alert customers when their balance is low: > > ```javascript theme={null} > if (account.balance.amount < minimumThreshold) { > await notifyCustomer({ > type: 'LOW_BALANCE', > account: account.id, > instructions: account.fundingPaymentInstructions > }); > } > ``` > [accordion] Handle multiple currencies efficiently > > If your platform supports multiple currencies, organize internal accounts by currency in your UI: > > ```javascript theme={null} > const accountsByCurrency = accounts.data.reduce((acc, account) => { > const code = account.balance.currency.code; > acc[code] = account; > return acc; > }, {}); > > // Quick lookup: accountsByCurrency['USD'] > > ``` > [accordion] Cache account data appropriately > > Internal account details (especially funding instructions) rarely change, so you can cache them safely. However, always fetch fresh balance data before initiating transfers. ## Next steps ![](/images/icons/arrow-path-right.svg) Rule-based accounts Issue dedicated USD account numbers or USDC and USDT deposit addresses that forward deposits automatically [Open](/payouts-and-b2b/depositing-funds/rule-based-accounts) ![](/images/icons/bank.svg) External accounts Learn how to add customer bank accounts as withdrawal destinations [Open](/payouts-and-b2b/depositing-funds/external-accounts) ![](/images/icons/globe.svg) Cross-currency transfers Use internal account balances to send international payments [Open](/payouts-and-b2b/quickstart) ![](/images/icons/code.svg) API Reference View complete API documentation for internal accounts [Open](/api-reference) # Required Fields by Corridor Source: https://agents-docs.lightspark.com/payouts-and-b2b/depositing-funds/required-fields Account and payer fields Grid needs per currency and rail The fields below are the details Grid requires for each currency and payment rail. Requirements change with the sender and recipient type, so set the filters to match your payment. Fields that are accepted but not required appear in the API reference for each account type. A field marked † is optional. Providing these fields enables Grid to route payments across more available partners. For EUR payments to an IBAN from Andorra, Holy See (Vatican City State), Monaco or San Marino, fields marked † are required. The Purpose of payment column shows if the quote must set `purposeOfPayment`, and which values the payment accepts. A quote with a value that is not accepted fails. ## Payout limits Minimum and maximum payout amounts are expressed in the destination currency. Limits may vary based on payment type (e.g., B2B, B2C, C2C), sender and recipient details, and available payment rails. In some corridors, instant payments are only supported up to a certain amount. Larger payments may be processed through alternative rails with higher limits but longer settlement times. Grid validates the applicable limits when a quote is created. Amounts outside the supported range return an `AMOUNT_OUT_OF_RANGE` error. SenderRecipientCurrency
          CurrencyRailSenderRecipient accountPurpose of paymentMinMax
          AEDBank TransferBirth date, Full name, Postal addressAddress, Beneficiary type, Full name, IBANRequired. Any value except `DONATION`450,000
          BDTBank TransferBirth date, Country of residence, Full name, Nationality, Postal addressAccount number, Bank name, Beneficiary type, Full name—500500,000
          BDTMobile MoneyBirth date, Full name, Nationality, Phone number, Postal addressBank name, Beneficiary type, Full name, Phone number—500500,000
          BRLPIXBirth date, Full name, Postal addressBeneficiary type, Full name, PIX key, PIX key type, Tax IDRequired. Any value except `DONATION`6032,000
          BWPMobile Money—Beneficiary type, Full name, Phone number, Provider—1509,500
          CHFBank TransferBirth date, Full name, Postal address (including city)Beneficiary type, Full name, IBAN—11,000,000
          CNYMobile MoneyBirth date, Country of residence, Full name, ID number, ID type, Nationality, Postal addressBank name, Beneficiary type, Full name, Phone number—150,000
          COPBank TransferBirth date, Country of residence, Full name, Postal addressAccount number, Address, Bank account type, Bank name, Beneficiary type, Document number, Document type, Full name, Phone number—10,00050,000,000
          COPMobile MoneyBirth date, Country of residence, Full name, Postal addressBank name, Beneficiary type, Full name, Phone number—10,00050,000,000
          DKKSEPABirth date, Full name, Postal address (including city† and postal code†)Beneficiary type, Full name, IBANRequired. Any value except `DONATION`10500,000
          DKKSEPA InstantBirth date, Full name, Postal address (including city† and postal code†)Beneficiary type, Full name, IBANRequired. Any value except `DONATION`10500,000
          EGPBank TransferBirth date, Country of residence, Full name, ID number, ID type, Nationality, Postal addressAddress, Bank name, Beneficiary type, Full name, IBAN—100500,000
          EGPMobile MoneyBirth date, Country of residence, Full name, ID number, ID type, Nationality, Postal addressBank name, Beneficiary type, Full name, Phone number—100500,000
          EURSEPABirth date†, Country of residence, Full name, Postal addressBeneficiary type, Country of residence, Full name, IBANRequired201,000,000
          EURSEPA InstantBirth date†, Country of residence, Full name, Postal addressBeneficiary type, Country of residence, Full name, IBANRequired201,000,000
          EURSWIFTBirth date, Full name, Postal addressBank name, Beneficiary type, Full name, Region, SWIFT codeRequired. Any value except `DONATION`201,000,000
          GBPFaster PaymentsBirth date, Full name, Postal addressAccount number, Beneficiary type, Full name, Sort codeRequired. Any value except `DONATION`11,000,000
          GHSBank TransferBirth date, Country of residence, Full name, Postal addressAccount number, Bank name, Beneficiary type, Full name—50100,000
          GHSMobile MoneyBirth date, Country of residence, Full name, Postal addressBank name, Beneficiary type, Full name, Phone number—5050,000
          GTQBank TransferBirth date, Country of residence, Full name, Postal addressAccount number, Bank account type, Bank name, Beneficiary type, Country of residence, Full name, Phone number—5056,600
          HKDBank TransferBirth date, Full name, Postal addressAccount number, Beneficiary type, Full name, SWIFT codeRequired. Any value except `DONATION`10100,000
          HTGMobile MoneyBirth date, Country of residence, Full name, Postal addressBeneficiary type, Full name, Phone number—500500,000
          IDRBank TransferBirth date, Full name, Postal addressAccount number, Bank name, Beneficiary type, Full name, Phone number, SWIFT codeRequired. Any value except `DONATION`33,936250,000,000
          ILSBank TransferBirth date, Full name, Postal addressBank name, Beneficiary type, Full name, IBAN—125,000
          INRNEFTBirth date, Full name, Postal addressAccount number, Beneficiary type, Full name, IFSCRequired. Any value except `EDUCATION`, `HEALTH_OR_MEDICAL`, `TAX_PAYMENT`, `LOAN_PAYMENT`, `DONATION`2001,500,000
          INRRTGSBirth date, Full name, Postal addressAccount number, Beneficiary type, Full name, IFSCRequired. Any value except `EDUCATION`, `HEALTH_OR_MEDICAL`, `TAX_PAYMENT`, `LOAN_PAYMENT`, `DONATION`2001,500,000
          INRUPIBirth date, Full name, Postal address (including city†)Beneficiary type, Full name, VPARequired. Any value except `EDUCATION`, `HEALTH_OR_MEDICAL`, `TAX_PAYMENT`, `LOAN_PAYMENT`, `DONATION`200200,000
          JMDBank TransferBirth date, Country of residence, Full name, Postal addressAccount number, Address, Bank account type, Bank name, Beneficiary type, Branch code, Full name, Phone number—500772,091
          KESMobile Money—Beneficiary type, Full name, Phone number, Provider—500250,000
          MWKMobile Money—Beneficiary type, Full name, Phone number, Provider—5,00040,000,000
          MXNSPEIBirth date†, Full name†, Postal address†Beneficiary type, CLABE number, Full name—1110,000,000
          MYRBank TransferBirth date, Full name, Postal addressAccount number, Bank name, Beneficiary type, Full name, SWIFT codeRequired. Any value except `DONATION`10300,000
          NGNBank TransferBirth date†, Full name†, Postal address†Account number, Bank name, Beneficiary type, Full name—1,80030,000,000
          PHPBank TransferBirth date, Full name, Postal address (including city†)Account number, Bank name, Beneficiary type, Full nameRequired. Any value except `DONATION`1001,000,000,000
          PKRBank TransferBirth date, Full name, Postal addressAccount number, Bank name, Beneficiary type, Full name—1,0001,000,000
          PKRMobile MoneyBirth date, Full name, Postal addressBank name, Beneficiary type, Full name, Phone number—1,0001,000,000
          RWFMobile Money—Beneficiary type, Full name, Phone number, Provider—1,50010,000,000
          SGDBank TransferBirth date, Full name, Postal addressAccount number, Beneficiary type, Full name, SWIFT codeRequired. Any value except `DONATION`1200,000
          SGDFASTBirth date, Full name, Postal addressAccount number, Beneficiary type, Full name, SWIFT codeRequired. Any value except `DONATION`1200,000
          SGDPayNowBirth date, Full name, Postal addressAccount number, Beneficiary type, Full name, SWIFT codeRequired. Any value except `DONATION`1200,000
          THBBank TransferBirth date, Full name, Postal addressAccount number, Bank name, Beneficiary type, Full name, SWIFT codeRequired. Any value except `DONATION`100700,000
          TRYBank TransferBirth date, Full name, Postal addressBank name, Beneficiary type, Country of residence, Full name, IBAN—11,000,000
          TZSMobile Money—Beneficiary type, Full name, Phone number, Provider—2,50010,000,000
          UGXMobile Money—Beneficiary type, Full name, Phone number, Provider—15,0003,000,000
          USDACHCountry of residence, FI legal entity name, Full nameAccount number, Beneficiary type, Full name, Routing number—1150,000
          USDSame Day ACHCountry of residence, FI legal entity name, Full nameAccount number, Beneficiary type, Full name, Routing number—1150,000
          USDFedNowCountry of residence, FI legal entity name, Full nameAccount number, Beneficiary type, Full name, Routing number—15,000,000
          USDRTPCountry of residence, FI legal entity name, Full nameAccount number, Beneficiary type, Full name, Routing number—15,000,000
          USDSWIFTBirth date, Full name, Postal addressBank name, Beneficiary type, Full name, Region, SWIFT codeRequired. Any value except `DONATION`110,000
          USDWireCountry of residence, FI legal entity name, Full nameAccount number, Beneficiary type, Full name, Routing number—1No max
          VNDBank TransferBirth date, Full name, Postal addressAccount number, Bank name, Beneficiary type, Full name, SWIFT codeRequired. Any value except `DONATION`10,000499,999,999
          XAFMobile Money—Beneficiary type, Full name, Phone number, Provider, Region—1,0001,000,000
          XOFMobile Money—Beneficiary type, Full name, Phone number, Provider, Region—5001,500,000
          ZARBank TransferBirth date†, Full name†, Postal address†Account number, Bank name, Beneficiary type, Full name—200500,000
          ZMWMobile Money—Beneficiary type, Full name, Phone number, Provider—100500,000
          AEDBank TransferBirth date, Full name, Postal addressAddress, Beneficiary type, IBAN, Legal nameRequired. Any value except `DONATION`450,000
          BRLPIXBirth date, Full name, Postal addressBeneficiary type, Legal name, PIX key, PIX key type, Tax IDRequired. Any value except `DONATION`6032,000
          BWPMobile Money—Beneficiary type, Legal name, Phone number, Provider, Registration number, Tax ID—1509,500
          DKKSEPABirth date, Full name, Postal addressBeneficiary type, IBAN, Legal nameRequired. Any value except `DONATION`10500,000
          DKKSEPA InstantBirth date, Full name, Postal addressBeneficiary type, IBAN, Legal nameRequired. Any value except `DONATION`10500,000
          EURSEPACountry of residence, Full name, Postal addressBeneficiary type, Country of residence, IBAN, Registration number, Tax IDRequired201,000,000
          EURSEPA InstantCountry of residence, Full name, Postal addressBeneficiary type, Country of residence, IBAN, Registration number, Tax IDRequired201,000,000
          EURSWIFTBirth date, Full name, Postal addressBank name, Beneficiary type, Legal name, Region, SWIFT codeRequired. Any value except `DONATION`201,000,000
          GBPFaster PaymentsBirth date, Full name, Postal addressAccount number, Beneficiary type, Legal name, Sort codeRequired. Any value except `DONATION`11,000,000
          HKDBank TransferBirth date, Full name, Postal addressAccount number, Beneficiary type, Legal name, SWIFT codeRequired. Any value except `DONATION`10100,000
          IDRBank TransferBirth date, Full name, Postal addressAccount number, Bank name, Beneficiary type, Legal name, Phone number, SWIFT codeRequired. Any value except `DONATION`33,936250,000,000
          INRNEFTBirth date, Full name, Postal addressAccount number, Beneficiary type, IFSC, Legal nameRequired. Any value except `DONATION`2001,500,000
          INRRTGSBirth date, Full name, Postal addressAccount number, Beneficiary type, IFSC, Legal nameRequired. Any value except `DONATION`2001,500,000
          INRUPIBirth date, Full name, Postal addressBeneficiary type, Legal name, VPARequired. Any value except `DONATION`200200,000
          KESMobile Money—Beneficiary type, Legal name, Phone number, Provider, Registration number, Tax ID—500250,000
          MWKMobile Money—Beneficiary type, Legal name, Phone number, Provider, Registration number, Tax ID—5,00040,000,000
          MXNSPEI—Beneficiary type, CLABE number, Legal name—1110,000,000
          MYRBank TransferBirth date, Full name, Postal addressAccount number, Bank name, Beneficiary type, Legal name, SWIFT codeRequired. Any value except `DONATION`10300,000
          NGNBank Transfer—Account number, Bank name, Beneficiary type, Legal name, Registration number, Tax ID—1,80030,000,000
          PHPBank TransferBirth date, Full name, Postal addressAccount number, Bank name, Beneficiary type, Legal nameRequired. Any value except `DONATION`1001,000,000,000
          RWFMobile Money—Beneficiary type, Legal name, Phone number, Provider, Registration number, Tax ID—1,50010,000,000
          SGDBank TransferBirth date, Full name, Postal addressAccount number, Beneficiary type, Legal name, SWIFT codeRequired. Any value except `DONATION`1200,000
          SGDFASTBirth date, Full name, Postal addressAccount number, Beneficiary type, Legal name, SWIFT codeRequired. Any value except `DONATION`1200,000
          SGDPayNowBirth date, Full name, Postal addressAccount number, Beneficiary type, Legal name, SWIFT codeRequired. Any value except `DONATION`1200,000
          THBBank TransferBirth date, Full name, Postal addressAccount number, Bank name, Beneficiary type, Legal name, SWIFT codeRequired. Any value except `DONATION`100700,000
          TZSMobile Money—Beneficiary type, Legal name, Phone number, Provider, Registration number, Tax ID—2,50010,000,000
          UGXMobile Money—Beneficiary type, Legal name, Phone number, Provider, Registration number, Tax ID—15,0003,000,000
          USDACHCountry of residence, FI legal entity name, Full nameAccount number, Beneficiary type, Legal name, Routing number—1150,000
          USDSame Day ACHCountry of residence, FI legal entity name, Full nameAccount number, Beneficiary type, Legal name, Routing number—1150,000
          USDFedNowCountry of residence, FI legal entity name, Full nameAccount number, Beneficiary type, Legal name, Routing number—15,000,000
          USDRTPCountry of residence, FI legal entity name, Full nameAccount number, Beneficiary type, Legal name, Routing number—15,000,000
          USDSWIFTBirth date, Full name, Postal addressBank name, Beneficiary type, Legal name, Region, SWIFT codeRequired. Any value except `DONATION`110,000
          USDWireCountry of residence, FI legal entity name, Full nameAccount number, Beneficiary type, Legal name, Routing number—1No max
          VNDBank TransferBirth date, Full name, Postal addressAccount number, Bank name, Beneficiary type, Legal name, SWIFT codeRequired. Any value except `DONATION`10,000499,999,999
          XAFMobile Money—Beneficiary type, Legal name, Phone number, Provider, Region, Registration number, Tax ID—1,0001,000,000
          XOFMobile Money—Beneficiary type, Legal name, Phone number, Provider, Region, Registration number, Tax ID—5001,500,000
          ZARBank Transfer—Account number, Bank name, Beneficiary type, Legal name, Registration number, Tax ID—200500,000
          ZMWMobile Money—Beneficiary type, Legal name, Phone number, Provider, Registration number, Tax ID—100500,000
          AEDBank TransferBusiness type, Company legal name, Postal address, Registration numberAddress, Beneficiary type, Full name, IBANRequired. Any value except `DONATION`450,000
          BDTBank TransferCompany legal name, Country of residence, Nationality, Phone number, Postal address, Registration numberAccount number, Bank name, Beneficiary type, Full name—500500,000
          BDTMobile MoneyCompany legal name, Nationality, Phone number, Postal address, Registration numberBank name, Beneficiary type, Full name, Phone number—500500,000
          BRLPIXBusiness type, Company legal name, Postal address, Registration numberBeneficiary type, Full name, PIX key, PIX key type, Tax IDRequired. Any value except `DONATION`6032,000
          BWPMobile Money—Beneficiary type, Full name, Phone number, Provider—1509,500
          CHFBank TransferCompany legal name, Postal address (including city), Registration numberBeneficiary type, Full name, IBAN—11,000,000
          CNYMobile MoneyCompany legal name, Postal address, Registration numberBank name, Beneficiary type, Full name, Phone number—150,000
          COPBank TransferCompany legal name, Country of residence, Postal address, Registration numberAccount number, Address, Bank account type, Bank name, Beneficiary type, Document number, Document type, Full name, Phone number—10,00050,000,000
          COPMobile MoneyCompany legal name, Country of residence, Postal address, Registration numberBank name, Beneficiary type, Full name, Phone number—10,00050,000,000
          DKKSEPABusiness type, Company legal name, Postal address (including city†), Registration numberBeneficiary type, Full name, IBANRequired. Any value except `DONATION`10500,000
          DKKSEPA InstantBusiness type, Company legal name, Postal address (including city†), Registration numberBeneficiary type, Full name, IBANRequired. Any value except `DONATION`10500,000
          EGPBank TransferCompany legal name, Country of residence, ID number, ID type, Nationality, Postal address, Registration numberAddress, Bank name, Beneficiary type, Full name, IBAN—100500,000
          EGPMobile MoneyCompany legal name, Country of residence, ID number, ID type, Nationality, Postal address, Registration numberBank name, Beneficiary type, Full name, Phone number—100500,000
          EURSEPACompany legal name, Country of residence, Postal address, Registration number†Beneficiary type, Country of residence, Full name, IBANRequired201,000,000
          EURSEPA InstantCompany legal name, Country of residence, Postal address, Registration number†Beneficiary type, Country of residence, Full name, IBANRequired201,000,000
          EURSWIFTBusiness type, Company legal name, Postal address, Registration numberBank name, Beneficiary type, Full name, Region, SWIFT codeRequired. Any value except `DONATION`201,000,000
          GBPFaster PaymentsBusiness type, Company legal name, Postal address, Registration numberAccount number, Beneficiary type, Full name, Sort codeRequired. Any value except `DONATION`11,000,000
          GHSBank TransferCompany legal name, Country of residence, Postal address, Registration numberAccount number, Bank name, Beneficiary type, Full name—50100,000
          GHSMobile MoneyCompany legal name, Country of residence, Postal address, Registration numberBank name, Beneficiary type, Full name, Phone number—5050,000
          GTQBank TransferCompany legal name, Country of residence, Postal address, Registration numberAccount number, Bank account type, Bank name, Beneficiary type, Country of residence, Full name, Phone number—5056,600
          HKDBank TransferBusiness type, Company legal name, Postal address, Registration numberAccount number, Beneficiary type, Full name, SWIFT codeRequired. Any value except `DONATION`10100,000
          HTGMobile MoneyCompany legal name, Country of residence, Postal address, Registration numberBeneficiary type, Full name, Phone number—500500,000
          IDRBank TransferBusiness type, Company legal name, Postal address, Registration numberAccount number, Bank name, Beneficiary type, Full name, Phone number, SWIFT codeRequired. Any value except `DONATION`33,936250,000,000
          ILSBank TransferCompany legal name, Postal address, Registration numberBank name, Beneficiary type, Full name, IBAN—1100,000
          INRNEFTBusiness type, Company legal name, Postal address, Registration numberAccount number, Beneficiary type, Full name, IFSCRequired. Any value except `EDUCATION`, `HEALTH_OR_MEDICAL`, `TAX_PAYMENT`, `LOAN_PAYMENT`, `DONATION`2001,500,000
          INRRTGSBusiness type, Company legal name, Postal address, Registration numberAccount number, Beneficiary type, Full name, IFSCRequired. Any value except `EDUCATION`, `HEALTH_OR_MEDICAL`, `TAX_PAYMENT`, `LOAN_PAYMENT`, `DONATION`2001,500,000
          INRUPIBusiness type, Company legal name, Postal address (including city†), Registration numberBeneficiary type, Full name, VPARequired. Any value except `EDUCATION`, `HEALTH_OR_MEDICAL`, `TAX_PAYMENT`, `LOAN_PAYMENT`, `DONATION`200200,000
          JMDBank TransferCompany legal name, Country of residence, Postal address, Registration numberAccount number, Address, Bank account type, Bank name, Beneficiary type, Branch code, Full name, Phone number—500772,091
          KESMobile Money—Beneficiary type, Full name, Phone number, Provider—500250,000
          MWKMobile Money—Beneficiary type, Full name, Phone number, Provider—5,00040,000,000
          MXNSPEICompany legal name†, Postal address†, Registration number†Beneficiary type, CLABE number, Full name—1110,000,000
          MYRBank TransferBusiness type, Company legal name, Postal address, Registration numberAccount number, Bank name, Beneficiary type, Full name, SWIFT codeRequired. Any value except `DONATION`10300,000
          NGNBank TransferCompany legal name†, Postal address†, Registration number†Account number, Bank name, Beneficiary type, Full name—1,80030,000,000
          PHPBank TransferBusiness type, Company legal name, Postal address, Registration numberAccount number, Bank name, Beneficiary type, Full nameRequired. Any value except `DONATION`1001,000,000,000
          PKRBank TransferCompany legal name, Postal address, Registration numberAccount number, Bank name, Beneficiary type, Full name—1,0001,000,000
          PKRMobile MoneyCompany legal name, Postal address, Registration numberBank name, Beneficiary type, Full name, Phone number—1,0001,000,000
          RWFMobile Money—Beneficiary type, Full name, Phone number, Provider—1,50010,000,000
          SGDBank TransferBusiness type, Company legal name, Postal address, Registration numberAccount number, Beneficiary type, Full name, SWIFT codeRequired. Any value except `DONATION`1200,000
          SGDFASTBusiness type, Company legal name, Postal address, Registration numberAccount number, Beneficiary type, Full name, SWIFT codeRequired. Any value except `DONATION`1200,000
          SGDPayNowBusiness type, Company legal name, Postal address, Registration numberAccount number, Beneficiary type, Full name, SWIFT codeRequired. Any value except `DONATION`1200,000
          THBBank TransferBusiness type, Company legal name, Postal address, Registration numberAccount number, Bank name, Beneficiary type, Full name, SWIFT codeRequired. Any value except `DONATION`100700,000
          TRYBank TransferCompany legal name, Postal address, Registration numberBank name, Beneficiary type, Country of residence, Full name, IBAN—11,000,000
          TZSMobile Money—Beneficiary type, Full name, Phone number, Provider—2,50010,000,000
          UGXMobile Money—Beneficiary type, Full name, Phone number, Provider—15,0003,000,000
          USDACHCompany legal name, Country of residence, FI legal entity nameAccount number, Beneficiary type, Full name, Routing number—1150,000
          USDSame Day ACHCompany legal name, Country of residence, FI legal entity nameAccount number, Beneficiary type, Full name, Routing number—1150,000
          USDFedNowCompany legal name, Country of residence, FI legal entity nameAccount number, Beneficiary type, Full name, Routing number—15,000,000
          USDRTPCompany legal name, Country of residence, FI legal entity nameAccount number, Beneficiary type, Full name, Routing number—15,000,000
          USDSWIFTBusiness type, Company legal name, Postal address, Registration numberBank name, Beneficiary type, Full name, Region, SWIFT codeRequired. Any value except `DONATION`110,000
          USDWireCompany legal name, Country of residence, FI legal entity nameAccount number, Beneficiary type, Full name, Routing number—1No max
          VNDBank TransferBusiness type, Company legal name, Postal address, Registration numberAccount number, Bank name, Beneficiary type, Full name, SWIFT codeRequired. Any value except `DONATION`10,000499,999,999
          XAFMobile Money—Beneficiary type, Full name, Phone number, Provider, Region—1,0001,000,000
          XOFMobile Money—Beneficiary type, Full name, Phone number, Provider, Region—5001,500,000
          ZARBank TransferCompany legal name†, Postal address†, Registration number†Account number, Bank name, Beneficiary type, Full name—200500,000
          ZMWMobile Money—Beneficiary type, Full name, Phone number, Provider—100500,000
          AEDBank TransferBusiness type, Company legal name, Postal address, Registration numberAddress, Beneficiary type, IBAN, Legal nameRequired. Any value except `DONATION`450,000
          BRLPIXBusiness type, Company legal name, Postal address, Registration numberBeneficiary type, Legal name, PIX key, PIX key type, Tax IDRequired. Any value except `DONATION`6032,000
          BWPMobile Money—Beneficiary type, Legal name, Phone number, Provider, Registration number, Tax ID—1509,500
          CHFBank TransferCompany legal name, Postal address (including city), Registration numberBeneficiary type, IBAN, Legal name—11,000,000
          CNYBank TransferCompany legal name, Postal address, Registration numberAccount number, Address, Bank name, Beneficiary type, Legal nameRequired. Only `TRAVEL`, `EXPORTED_GOODS_PREPAYMENT`, `EXPORTED_GOODS_POSTPAYMENT`, `HOTEL_ACCOMMODATION`, `DELIVERY_FEES`, `OFFICE_EXPENSES`, `COMMISSION_ON_GOODS`, `COMMISSION_ON_SERVICES`, `ACCOUNTING_SERVICES`, `EXHIBITION_SERVICES`110,000,000
          COPBank TransferCompany legal name, Country of residence, Postal address, Registration numberAccount number, Bank account type, Bank name, Beneficiary type, Legal name, Tax ID—10,00050,000,000
          DKKSEPABusiness type, Company legal name, Postal address, Registration numberBeneficiary type, IBAN, Legal nameRequired. Any value except `DONATION`10500,000
          DKKSEPA InstantBusiness type, Company legal name, Postal address, Registration numberBeneficiary type, IBAN, Legal nameRequired. Any value except `DONATION`10500,000
          EGPBank TransferCompany legal name, Postal address, Registration numberAddress, Bank name, Beneficiary type, IBAN, Legal name—100500,000
          EURSEPACompany legal name, Country of residence, Postal addressBeneficiary type, Country of residence, IBAN, Registration number, Tax IDRequired201,000,000
          EURSEPA InstantCompany legal name, Country of residence, Postal addressBeneficiary type, Country of residence, IBAN, Registration number, Tax IDRequired201,000,000
          EURSWIFTBusiness type, Company legal name, Postal address, Registration numberBank name, Beneficiary type, Legal name, Region, SWIFT codeRequired. Any value except `DONATION`201,000,000
          GBPFaster PaymentsBusiness type, Company legal name, Postal address, Registration numberAccount number, Beneficiary type, Legal name, Sort codeRequired. Any value except `DONATION`11,000,000
          HKDBank TransferBusiness type, Company legal name, Postal address, Registration numberAccount number, Beneficiary type, Legal name, SWIFT codeRequired. Any value except `DONATION`10100,000
          IDRBank TransferBusiness type, Company legal name, Postal address, Registration numberAccount number, Bank name, Beneficiary type, Legal name, Phone number, SWIFT codeRequired. Any value except `DONATION`33,936250,000,000
          ILSBank TransferCompany legal name, Postal address, Registration numberAddress, Bank name, Beneficiary type, IBAN, Legal name—1600,000
          INRNEFTBusiness type, Company legal name, Postal address, Registration numberAccount number, Beneficiary type, IFSC, Legal nameRequired. Any value except `DONATION`2001,500,000
          INRRTGSBusiness type, Company legal name, Postal address, Registration numberAccount number, Beneficiary type, IFSC, Legal nameRequired. Any value except `DONATION`2001,500,000
          INRUPIBusiness type, Company legal name, Postal address, Registration numberBeneficiary type, Legal name, VPARequired. Any value except `DONATION`200200,000
          KESMobile Money—Beneficiary type, Legal name, Phone number, Provider, Registration number, Tax ID—500250,000
          MWKMobile Money—Beneficiary type, Legal name, Phone number, Provider, Registration number, Tax ID—5,00040,000,000
          MXNSPEI—Beneficiary type, CLABE number, Legal name—1110,000,000
          MYRBank TransferBusiness type, Company legal name, Postal address, Registration numberAccount number, Bank name, Beneficiary type, Legal name, SWIFT codeRequired. Any value except `DONATION`10300,000
          NGNBank Transfer—Account number, Bank name, Beneficiary type, Legal name, Registration number, Tax ID—1,80030,000,000
          PHPBank TransferBusiness type, Company legal name, Postal address, Registration numberAccount number, Bank name, Beneficiary type, Legal nameRequired. Any value except `DONATION`1001,000,000,000
          RWFMobile Money—Beneficiary type, Legal name, Phone number, Provider, Registration number, Tax ID—1,50010,000,000
          SGDBank TransferBusiness type, Company legal name, Postal address, Registration numberAccount number, Beneficiary type, Legal name, SWIFT codeRequired. Any value except `DONATION`1200,000
          SGDFASTBusiness type, Company legal name, Postal address, Registration numberAccount number, Beneficiary type, Legal name, SWIFT codeRequired. Any value except `DONATION`1200,000
          SGDPayNowBusiness type, Company legal name, Postal address, Registration numberAccount number, Beneficiary type, Legal name, SWIFT codeRequired. Any value except `DONATION`1200,000
          THBBank TransferBusiness type, Company legal name, Postal address, Registration numberAccount number, Bank name, Beneficiary type, Legal name, SWIFT codeRequired. Any value except `DONATION`100700,000
          TRYBank TransferCompany legal name, Postal address, Registration numberBank name, Beneficiary type, Country of residence, IBAN, Legal name—15,000,000
          TZSMobile Money—Beneficiary type, Legal name, Phone number, Provider, Registration number, Tax ID—2,50010,000,000
          UGXMobile Money—Beneficiary type, Legal name, Phone number, Provider, Registration number, Tax ID—15,0003,000,000
          USDACHCompany legal name, Country of residence, FI legal entity nameAccount number, Beneficiary type, Legal name, Routing number—1150,000
          USDSame Day ACHCompany legal name, Country of residence, FI legal entity nameAccount number, Beneficiary type, Legal name, Routing number—1150,000
          USDFedNowCompany legal name, Country of residence, FI legal entity nameAccount number, Beneficiary type, Legal name, Routing number—15,000,000
          USDRTPCompany legal name, Country of residence, FI legal entity nameAccount number, Beneficiary type, Legal name, Routing number—15,000,000
          USDSWIFTBusiness type, Company legal name, Postal address, Registration numberBank name, Beneficiary type, Legal name, Region, SWIFT codeRequired. Any value except `DONATION`110,000
          USDWireCompany legal name, Country of residence, FI legal entity nameAccount number, Beneficiary type, Legal name, Routing number—1No max
          VNDBank TransferBusiness type, Company legal name, Postal address, Registration numberAccount number, Bank name, Beneficiary type, Legal name, SWIFT codeRequired. Any value except `DONATION`10,000499,999,999
          XAFMobile Money—Beneficiary type, Legal name, Phone number, Provider, Region, Registration number, Tax ID—1,0001,000,000
          XOFMobile Money—Beneficiary type, Legal name, Phone number, Provider, Region, Registration number, Tax ID—5001,500,000
          ZARBank Transfer—Account number, Bank name, Beneficiary type, Legal name, Registration number, Tax ID—200500,000
          ZMWMobile Money—Beneficiary type, Legal name, Phone number, Provider, Registration number, Tax ID—100500,000
          No corridor matches these filters. Try another currency, or a different sender or recipient type. # Rule-Based Accounts Source: https://agents-docs.lightspark.com/payouts-and-b2b/depositing-funds/rule-based-accounts Issue dedicated USD account numbers or USDC and USDT deposit addresses that forward every incoming payment to a destination you choose A rule-based account is an additional account number for an account holder, with a routing rule attached. Every payment that settles into it is attributed to that account and then swept to the rule's destination. Issue one account per payer, invoice, or use case, and you can reconcile incoming payments by the account they arrived at instead of matching on remittance text. Rule-based accounts never hold a balance. Funds that land in one are always moved on, either to the rule's destination or, if the sweep can't complete, to the account holder's own internal account in the same currency. The exception is an EU account whose payout fails after the conversion: those funds wait for an authorized transfer (see [Funds a sweep couldn't deliver](#funds-a-sweep-couldnt-deliver)). ## Supported currencies | Currency | What payers send to | | - | - | | `USD` | A dedicated US bank account and routing number | | `USDC` | A dedicated deposit address on each supported network for your platform (for example, Solana or Base) | | `USDT` | A dedicated deposit address on each supported network for your platform (for example, Tron or Ethereum) | ## Prerequisites * The account holder already has an internal account in the same currency. Funds go there when a sweep can't be completed. * The account holder is verified. For a platform-owned account, your platform's own customer record must be verified. * The sweep destination exists. It can be an external account or another internal account, but not another rule-based account. ## Create a rule-based account Call `POST /internal-accounts` with `type: RULE_BASED`, the currency, and a `sweepRule`. Omit `customerId` to create an account owned by your platform. > [!TIP] > > Always send an `Idempotency-Key`. Each request issues a new account number or address that can't be reversed, so the key is what keeps a retry from creating a second one. > [tab] USD > > Sweep every incoming dollar payment to the customer's external bank account over ACH: > > ```bash theme={null} > curl -X POST 'https://api.lightspark.com/grid/2025-10-13/internal-accounts' \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -H 'Content-Type: application/json' \ > -H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000' \ > -d '{ > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "type": "RULE_BASED", > "currency": "USD", > "label": "invoice-4417", > "sweepRule": { > "destination": { > "accountId": "ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", > "paymentRail": "ACH" > }, > "purposeOfPayment": "SELF", > "description": "Rent sweep", > "remittanceInformation": "Unit 4B March" > } > }' > ``` > [tab] USDC > > Sweep every incoming USDC deposit to the customer's USD internal account. The sweep is converted at the prevailing rate: > > ```bash theme={null} > curl -X POST 'https://api.lightspark.com/grid/2025-10-13/internal-accounts' \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -H 'Content-Type: application/json' \ > -H 'Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7' \ > -d '{ > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "type": "RULE_BASED", > "currency": "USDC", > "label": "payer-northwind", > "sweepRule": { > "destination": { > "accountId": "InternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" > } > } > }' > ``` The response is the new internal account. Share its `fundingPaymentInstructions` with the payer: a bank account number for USD, or one wallet address per network for USDC and USDT. If the account's `status` is `PENDING`, wait for `ACTIVE` before sharing them. > [accordion] USDC response > > ```json theme={null} > { > "id": "InternalAccount:3f1c9a2e-7b4d-4e8a-9c21-5d6e7f8a9b0c", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "type": "RULE_BASED", > "status": "ACTIVE", > "label": "payer-northwind", > "balance": { > "amount": 0, > "currency": { "code": "USDC", "name": "USD Coin", "symbol": "USDC", "decimals": 6 } > }, > "totalBalance": { > "amount": 0, > "currency": { "code": "USDC", "name": "USD Coin", "symbol": "USDC", "decimals": 6 } > }, > "fundingPaymentInstructions": [ > { > "accountOrWalletInfo": { > "accountType": "SOLANA_WALLET", > "assetType": "USDC", > "address": "8Hq2vWkN5mJtXcR3pL7yBdF9sA4eG6uZ1oKiT2nV3wQx" > } > } > ], > "sweepRule": { > "destination": { > "accountId": "InternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" > } > }, > "createdAt": "2026-10-27T12:00:00Z", > "updatedAt": "2026-10-27T12:00:00Z" > } > ``` ## Configure the sweep rule | Field | Description | | - | - | | `destination.accountId` | Where funds are swept. It can be in a different currency, in which case each sweep is converted at the prevailing rate. | | `destination.paymentRail` | The rail used for an external destination. Omit it to pick a rail automatically for each sweep. Not accepted for internal destinations. | | `purposeOfPayment` | Applied to each sweep. Some destination countries require it. | | `description` | Recorded on each sweep for your own records. Not sent to the recipient. | | `remittanceInformation` | Sent with each sweep to the recipient, for example in the ACH addenda or wire OBI. Printable ASCII only. | | `platformFeeOverride` | Fee terms for every sweep this rule drives. Omit it to use your platform's configured fees. | When you read the account, `sweepRule.minimumAmount` and `sweepRule.maximumAmount` show the range the route to the destination can carry, in the rule-based account's currency. ## Track sweeps Each sweep appears as a transaction with `ruleBasedAccountId` set to the rule-based account that received the deposit. Its `source` describes the depositor when it's known. A sweep that can't be completed fails with one of these reasons, and the funds move to the account holder's own internal account in the same currency: * `SWEEP_AMOUNT_OUT_OF_RANGE`: The amount is below `minimumAmount` or above `maximumAmount`. * `SWEEP_QUOTE_FAILED`: Grid couldn't price the sweep. A sweep that was priced and then failed on the way to settlement reports `QUOTE_EXECUTION_FAILED`, the same as any other payment. Include it alongside the `SWEEP_*` reasons when you monitor for failed sweeps. To list an account holder's rule-based accounts, filter internal accounts with `type=RULE_BASED`. ## EU customers For customers in a region where Strong Customer Authentication (SCA) is required, a rule-based account converts every deposit and sends the result to its destination. The sweep runs without any further action once the account holder has authorized it. | Currency | What payers send to | Supported destinations | | - | - | - | | `EUR` | A dedicated IBAN | A crypto external account owned by the account holder (for example, a USDC wallet), or the account holder's own `USDC` or `BTC` internal account | | `USDC` | A dedicated deposit address | An `EUR` external bank account owned by the account holder, or their own `EUR` internal account | | `BTC` | A dedicated deposit address | An `EUR` external bank account owned by the account holder, or their own `EUR` internal account | A same-currency destination isn't supported, and neither are `remittanceInformation` or `purposeOfPayment` on the sweep rule. A customer can hold up to 10 rule-based accounts. ### Authorize the account The create request returns `202` with the account in `PENDING_AUTHORIZATION` and an `scaChallenge`. The account holder authorizes it once, and later sweeps run without further authorization: **1. Confirm an SCA session** Make sure the customer has an active SCA login session. A missing session returns `409 SCA_SESSION_REQUIRED`. **2. Trust an external destination** For an external destination, start beneficiary trust with `POST /customers/external-accounts/{externalAccountId}/trust`, then submit the customer's proof to `POST /customers/external-accounts/{externalAccountId}/trust/confirm`. Check that the response contains `trusted: true` before creating the account. An untrusted destination returns `409 BENEFICIARY_NOT_TRUSTED`. **3. Create the account** Call `POST /internal-accounts` with an `Idempotency-Key`. Pass `scaFactor` to choose `SMS_OTP` (default), `TOTP`, or `PASSKEY`. **4. Authorize the challenge** Call `POST /internal-accounts/{id}/authorize` with the customer's proof, then check the returned account. If it is still `PENDING_AUTHORIZATION`, authorize its next `scaChallenge` and repeat until it advances. Once the account is `ACTIVE`, Grid sends `INTERNAL_ACCOUNT.STATUS_UPDATED`. If the challenge expires, repeat the create request with the same `Idempotency-Key` to receive a new challenge. An account that is never authorized moves to `FAILED`. ### Funds a sweep couldn't deliver If a payout is refused after the conversion (for example, by the destination bank or a compliance check), the converted funds stay with the account holder. They are listed in the account's `strandedBalances`. Move them to the account holder's own internal account in that currency with `POST /internal-accounts/{id}/stranded-funds/transfer`, which returns a quote to authorize like any other SCA-protected transfer. ## Test in sandbox In sandbox, account numbers and addresses are generated locally. Call `POST /sandbox/internal-accounts/{accountId}/fund` on a rule-based account to simulate a settled deposit. It triggers the sweep exactly as a real deposit would. Fund a very small amount to test the `SWEEP_AMOUNT_OUT_OF_RANGE` path. ## Next steps ![](/images/icons/wallet1.svg) Internal accounts Learn about the accounts that receive failed sweeps [Open](/payouts-and-b2b/depositing-funds/internal-accounts) ![](/images/icons/bank.svg) External accounts Add the bank accounts and wallets that sweeps pay out to [Open](/payouts-and-b2b/depositing-funds/external-accounts) ![](/images/icons/code.svg) API Reference View complete API documentation for creating internal accounts [Open](/api-reference) # Authentication Source: https://agents-docs.lightspark.com/payouts-and-b2b/global-accounts/authentication Register, reauthenticate, and manage Global Account credentials (passkey, OAuth, email OTP) Every Global Account action beyond receiving funds must be authorized by a session signing key. Sessions are issued by verifying one of four **credential types** on the account: | Type | When to use it | | - | - | | **`PASSKEY`** | Best default. Biometric, phishing-resistant, usable across the user's devices via iCloud Keychain / Google Password Manager. | | **`OAUTH`** | Your platform already authenticates the user via OIDC (Google, Apple, your own IdP) and you want Grid to trust the same identity. | | **`EMAIL_OTP`** | Lowest-friction option. Works on any device with email access — no biometric hardware, identity provider, or client SDK required beyond the code entry field. | | **`SMS_OTP`** | Same flow as `EMAIL_OTP`, but delivered to the user's phone number instead of email. Useful when phone is the primary contact channel. | A single internal account can hold one `EMAIL_OTP` credential, one `SMS_OTP` credential, and multiple distinct `PASSKEY` credentials concurrently. `OAUTH` credentials can be added for each supported provider identity. ## Registration vs. verification Global Accounts are initialized with an `EMAIL_OTP` credential tied to the customer email on the internal account. Use that credential to mint the first session, or use an already-registered `OAUTH` or `PASSKEY` credential after you add one. To produce a session: * **`EMAIL_OTP`** — if you need a fresh OTP or encryption target, first call **`POST /auth/credentials/{id}/challenge`**. Generate a P-256 client key pair, HPKE-encrypt the OTP value plus the public key into `encryptedOtpBundle`, then call **`POST /auth/credentials/{id}/verify`**. Grid returns a `202` signed-retry challenge; stamp its `payloadToSign` with the same private key and retry with `Grid-Wallet-Signature` plus `Request-Id`. The signed retry returns the session; the private key you generated is the session signing key. * **`OAUTH`** — call **`POST /auth/credentials/{id}/verify`** with a fresh OIDC token plus `clientPublicKey`. The response is the session; the private key matching `clientPublicKey` is the session signing key. * **`PASSKEY`** — call **`POST /auth/credentials/{id}/challenge`** with your `clientPublicKey` to receive a Grid-issued `challenge` and `requestId`, UTF-8 encode the challenge string for `navigator.credentials.get()`, then call **`POST /auth/credentials/{id}/verify`** with the resulting assertion and the `Request-Id` header. Grid bakes the `clientPublicKey` from the `/challenge` call into the session-creation payload, so it does **not** appear on the `/verify` body. To add another credential, call **`POST /auth/credentials`** with the new credential details. Because the account already has an `EMAIL_OTP` credential, Grid returns a `202` signed-retry challenge. Stamp that `payloadToSign` with an active session signing key, then retry the same request with `Grid-Wallet-Signature` and `Request-Id`. The signed retry returns the new `AuthMethod`. Re-authentication after a session expires skips the original `POST /auth/credentials` create call but otherwise follows the same per-type pattern. `EMAIL_OTP` re-auth needs a fresh OTP email, so call `POST /auth/credentials/{id}/challenge` first; `PASSKEY` re-auth uses the same `/challenge` (with `clientPublicKey`) → `/verify` two-step as the first authentication; `OAUTH` is the exception — since proof of control is a fresh OIDC token, there's nothing to pre-issue, so `/verify` alone suffices. ## Passkey ### Passkey registration Passkey registration spans four parties: the **client** (browser or app), your **integrator backend**, **Grid**, and the platform authenticator (Touch ID / Face ID / Windows Hello / a security key). Your backend issues the WebAuthn registration challenge. Registering the passkey on Grid is an additional-credential signed-retry flow, authorized by an active session from an existing credential. The first authentication challenge is requested explicitly via `POST /auth/credentials/{id}/challenge` — same call shape used for every subsequent reauthentication. ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid participant A as Authenticator C->>IB: Start registration IB->>IB: Generate random challenge (stored in session) IB-->>C: { challenge, rpId, user } C->>A: navigator.credentials.create({ challenge, … }) A-->>C: attestation (credentialId, clientDataJson, attestationObject) C->>IB: POST /my-backend/passkey/register (attestation) IB->>G: POST /auth/credentials { type: PASSKEY, challenge, attestation, … } G-->>IB: 202 { payloadToSign, requestId, expiresAt } IB-->>C: { payloadToSign, requestId } C->>C: stamp(payloadToSign, existingSessionPrivateKey) C->>IB: { stamp, requestId } IB->>G: Same POST
          Grid-Wallet-Signature: stamp
          Request-Id: requestId G-->>IB: 201 AuthMethod { id, type, accountId, … } IB-->>C: { credentialId } C->>C: generateClientKeyPair() C->>IB: POST /my-backend/passkey/challenge (clientPublicKey) IB->>G: POST /auth/credentials/{id}/challenge
          { clientPublicKey } G-->>IB: 200 PasskeyAuthChallenge { challenge, requestId, expiresAt } IB-->>C: { challenge, requestId } C->>A: navigator.credentials.get({ challenge, … }) A-->>C: assertion (credentialId, clientDataJson, authenticatorData, signature) C->>IB: POST /my-backend/passkey/verify (assertion, requestId) IB->>G: POST /auth/credentials/{id}/verify
          Request-Id: requestId
          { type: PASSKEY, assertion } G-->>IB: 200 AuthSession { id, expiresAt } IB-->>C: { sessionId, expiresAt } C->>C: hold the client private key as the session signing key ``` The `challenge` on `POST /auth/credentials` is the one your backend issued for WebAuthn registration. The signed-retry `payloadToSign` is the registration payload that the customer's current session authorizes. The challenge used for the WebAuthn assertion comes later from `POST /auth/credentials/{id}/challenge` — Grid bakes the `clientPublicKey` you send there into the session-creation payload, so the resulting session belongs to the key the device generated. Because that key plumbing happens on `/challenge`, the subsequent `/verify` call carries only the assertion (plus the `Request-Id` header). > [!TIP] > > Passkeys are domain-bound. Before shipping, set up your `/.well-known/apple-app-site-association` and `/.well-known/assetlinks.json` entries so the platform authenticator binds the passkey to your origin and app bundles: > > * Web: [passkeys.dev bootstrapping guide](https://passkeys.dev/docs/use-cases/bootstrapping/#opting-the-user-into-passkeys) > * Android: [Create passkeys on Android](https://developer.android.com/identity/passkeys/create-passkeys) > * iOS: [Supporting passkeys](https://developer.apple.com/documentation/authenticationservices/supporting-passkeys) #### Client sample code The client never talks to Grid. It talks to your integrator backend — the snippets below simulate that call with `fetch('/my-backend/...')`, which your backend then relays to Grid. ```typescript Web (TypeScript) theme={null} // 1. Ask your backend for a registration challenge. const startRes = await fetch("/my-backend/passkey/register/start", { method: "POST", credentials: "include", }); const { challenge, rpId, user } = await startRes.json(); // 2. Ask the authenticator to create a passkey. const attestation = (await navigator.credentials.create({ publicKey: { challenge: base64urlToBytes(challenge), rp: { id: rpId, name: "Acme Wallet" }, user: { id: base64urlToBytes(user.id), name: user.email, displayName: user.displayName, }, pubKeyCredParams: [{ type: "public-key", alg: -7 }], // ES256 authenticatorSelection: { residentKey: "required", userVerification: "required" }, timeout: 60_000, }, })) as PublicKeyCredential; const att = attestation.response as AuthenticatorAttestationResponse; // 3. Send the attestation to your backend, which starts POST /auth/credentials. const registerRes = await fetch("/my-backend/passkey/register", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nickname: "This device", credentialId: bytesToBase64url(new Uint8Array(attestation.rawId)), clientDataJson: bytesToBase64url(new Uint8Array(att.clientDataJSON)), attestationObject: bytesToBase64url(new Uint8Array(att.attestationObject)), transports: att.getTransports?.() ?? [], }), }); const { payloadToSign, requestId: registrationRequestId } = await registerRes.json(); // 4. Stamp the registration payload with an existing session signing key, then // complete the signed retry. The backend retries the same Grid request with // Grid-Wallet-Signature and Request-Id. const gridWalletSignature = await buildGridWalletSignature( existingSessionPrivateKeyBytes, payloadToSign, ); const registerCompleteRes = await fetch("/my-backend/passkey/register/complete", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requestId: registrationRequestId, gridWalletSignature }), }); const { credentialId } = await registerCompleteRes.json(); // 5. Generate the client key pair and ask Grid for an authentication challenge // bound to its public key. const { privateKey, publicKeyHex } = generateClientKeyPair(); const challengeRes = await fetch("/my-backend/passkey/challenge", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ credentialId, clientPublicKey: publicKeyHex }), }); const { challenge: gridChallenge, requestId: authRequestId } = await challengeRes.json(); // 6. Run the WebAuthn assertion against the Grid-issued challenge. This // challenge is a lowercase hex string; UTF-8 encode it exactly as returned. const assertion = (await navigator.credentials.get({ publicKey: { challenge: new TextEncoder().encode(gridChallenge), rpId, userVerification: "required", allowCredentials: [{ type: "public-key", id: base64urlToBytes(credentialId) }], }, })) as PublicKeyCredential; const asr = assertion.response as AuthenticatorAssertionResponse; // 7. Send the assertion to your backend; it relays to POST /verify with Request-Id. const verifyRes = await fetch("/my-backend/passkey/verify", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ credentialId, requestId: authRequestId, assertion: { credentialId: bytesToBase64url(new Uint8Array(assertion.rawId)), clientDataJson: bytesToBase64url(new Uint8Array(asr.clientDataJSON)), authenticatorData: bytesToBase64url(new Uint8Array(asr.authenticatorData)), signature: bytesToBase64url(new Uint8Array(asr.signature)), userHandle: asr.userHandle ? bytesToBase64url(new Uint8Array(asr.userHandle)) : null, }, }), }); const { sessionId, expiresAt } = await verifyRes.json(); // privateKey is now the session signing key until expiresAt — see client-keys.mdx. ``` ```kotlin Android (Kotlin) theme={null} // Uses Jetpack Credential Manager. // implementation("androidx.credentials:credentials:1.3.0") // implementation("androidx.credentials:credentials-play-services-auth:1.3.0") import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.CredentialManager import androidx.credentials.GetCredentialRequest import androidx.credentials.GetPublicKeyCredentialOption import androidx.credentials.PublicKeyCredential suspend fun registerPasskey(context: Context, api: MyBackendApi): EmbeddedWalletSession { // 1. Backend → WebAuthn registration options JSON (challenge, rp, user, ...). val createOptionsJson = api.passkeyRegisterStart() val cm = CredentialManager.create(context) // 2. Authenticator creates the passkey. val createResp = cm.createCredential( context = context, request = CreatePublicKeyCredentialRequest(createOptionsJson), ) as androidx.credentials.CreatePublicKeyCredentialResponse val attestationJson = createResp.registrationResponseJson // 3. Backend -> POST /auth/credentials. Returns a signed-retry challenge. val registerChallenge = api.passkeyRegister(attestationJson, nickname = "This device") // 4. Client stamps the registration payload with an existing session key. val gridWalletSignature = buildGridWalletSignature( existingSessionKeys, registerChallenge.payloadToSign, ) val registerResp = api.passkeyRegisterComplete( requestId = registerChallenge.requestId, gridWalletSignature = gridWalletSignature, ) // 5. Generate client key pair, then ask Grid for an authentication challenge // bound to that public key via POST /auth/credentials/{id}/challenge. val clientKeys = generateClientKeyPair(alias = "embedded-wallet-${registerResp.credentialId}") val challengeResp = api.passkeyChallenge( credentialId = registerResp.credentialId, clientPublicKeyHex = clientKeys.publicKeyHex, ) // 6. Run WebAuthn assertion against Grid-issued challenge. The returned // value is a lowercase hex string; UTF-8 encode it to bytes, then put // those bytes into the platform WebAuthn request format. val webAuthnChallenge = base64UrlEncode( challengeResp.challenge.toByteArray(Charsets.UTF_8), ) val getOptionsJson = buildWebAuthnGetOptionsJson( challenge = webAuthnChallenge, rpId = registerResp.rpId, credentialId = registerResp.credentialId, ) val getResp = cm.getCredential( context = context, request = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(getOptionsJson))), ).credential as PublicKeyCredential val assertionJson = getResp.authenticationResponseJson // 7. Backend -> POST /verify with Request-Id. Returns the AuthSession. return api.passkeyVerify( credentialId = registerResp.credentialId, requestId = challengeResp.requestId, assertionJson = assertionJson, ) } ``` ```swift iOS (Swift) theme={null} import AuthenticationServices final class PasskeyCoordinator: NSObject, ASAuthorizationControllerDelegate { private let api: MyBackendApi private var continuation: CheckedContinuation? private var clientKeys: ClientKeyPair? private var pendingCredentialId: String? private var pendingRequestId: String? init(api: MyBackendApi) { self.api = api } func registerPasskey() async throws -> EmbeddedWalletSession { // 1. Backend → WebAuthn registration options. let options = try await api.passkeyRegisterStart() // 2. Authenticator creates the passkey. let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( relyingPartyIdentifier: options.rpId, ) let request = provider.createCredentialRegistrationRequest( challenge: options.challenge, name: options.user.name, userID: options.user.id, ) let attestation = try await withCheckedThrowingContinuation { (cont: CheckedContinuation< ASAuthorizationPlatformPublicKeyCredentialRegistration, Error >) in let controller = ASAuthorizationController(authorizationRequests: [request]) controller.delegate = AttestationDelegate(cont: cont) controller.performRequests() } // 3. Backend -> POST /auth/credentials. Returns a signed-retry challenge. let registerChallenge = try await api.passkeyRegister( credentialId: attestation.credentialID.base64URLEncoded, clientDataJson: attestation.rawClientDataJSON.base64URLEncoded, attestationObject: attestation.rawAttestationObject!.base64URLEncoded, nickname: "This device", ) // 4. Client stamps the registration payload with an existing session key. let gridWalletSignature = try buildGridWalletSignature( signingKey: existingSessionSigningKey, payloadToSign: registerChallenge.payloadToSign, ) let registered = try await api.passkeyRegisterComplete( requestId: registerChallenge.requestId, gridWalletSignature: gridWalletSignature, ) // 5. Generate client key pair, ask Grid for an authentication challenge // bound to that public key via POST /auth/credentials/{id}/challenge. let keys = generateClientKeyPair() let challengeResp = try await api.passkeyChallenge( credentialId: registered.credentialId, clientPublicKeyHex: keys.publicKeyHex, ) // 6. Run WebAuthn assertion against Grid-issued challenge. The returned // value is a lowercase hex string; UTF-8 encode it as challenge bytes. let assertRequest = provider.createCredentialAssertionRequest( challenge: Data(challengeResp.challenge.utf8), ) assertRequest.allowedCredentials = [ ASAuthorizationPlatformPublicKeyCredentialDescriptor( credentialID: Data(base64URLEncoded: registered.credentialId)!, ), ] let assertion = try await withCheckedThrowingContinuation { (cont: CheckedContinuation< ASAuthorizationPlatformPublicKeyCredentialAssertion, Error >) in let controller = ASAuthorizationController(authorizationRequests: [assertRequest]) controller.delegate = AssertionDelegate(cont: cont) controller.performRequests() } // 7. Backend -> POST /verify with Request-Id. Returns the AuthSession. return try await api.passkeyVerify( credentialId: registered.credentialId, requestId: challengeResp.requestId, clientDataJson: assertion.rawClientDataJSON.base64URLEncoded, authenticatorData: assertion.rawAuthenticatorData.base64URLEncoded, signature: assertion.signature.base64URLEncoded, userHandle: assertion.userID?.base64URLEncoded, ) } } ``` #### WebAuthn → Grid parameter map These are the fields you need to pass through on each hop. **Registration (`/auth/credentials`):** | Browser (`credential`) | Your backend payload | Grid request body | | - | - | - | | `credential.rawId` | `credentialId` | `attestation.credentialId` | | `response.clientDataJSON` | `clientDataJson` | `attestation.clientDataJson` | | `response.attestationObject` | `attestationObject` | `attestation.attestationObject` | | `response.getTransports()` | `transports` | `attestation.transports` | | *(backend session state)* | `challenge` | `challenge` *(top-level, not under `attestation`)* | | *(backend-chosen)* | `nickname` | `nickname` | | *(Grid account id)* | — | `accountId` | **Authentication challenge (`/auth/credentials/{id}/challenge`):** | Source | Your backend payload | Grid request body | | - | - | - | | *(client-generated)* | `clientPublicKey` | `clientPublicKey` | **Assertion (`/auth/credentials/{id}/verify`):** | Browser (`credential`) | Your backend payload | Grid request body | | - | - | - | | `credential.rawId` | `credentialId` | `assertion.credentialId` | | `response.clientDataJSON` | `clientDataJson` | `assertion.clientDataJson` | | `response.authenticatorData` | `authenticatorData` | `assertion.authenticatorData` | | `response.signature` | `signature` | `assertion.signature` | | `response.userHandle` | `userHandle` | `assertion.userHandle` *(optional)* | | *(from `/challenge` response)* | `requestId` | `Request-Id` header | > [!NOTE] > > The WebAuthn spec names the field `clientDataJSON`. Grid spells it `clientDataJson` (lowercase `json`) for consistency with the rest of the API. The **bytes are identical** — only the field name changes. ### Passkey reauthentication When a session expires the client re-verifies without recreating the credential. Reauthentication uses the same `/challenge` → `/verify` shape as the first authentication: generate a fresh client key pair, call `POST /auth/credentials/{id}/challenge` with the new `clientPublicKey`, UTF-8 encode the returned challenge string for `navigator.credentials.get()`, then call `/verify` with the assertion and the matching `Request-Id` header. ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid participant A as Authenticator C->>IB: session expired, re-authenticate C->>C: generateClientKeyPair() C->>IB: POST /my-backend/passkey/challenge (clientPublicKey) IB->>G: POST /auth/credentials/{id}/challenge
          { clientPublicKey } G-->>IB: 200 PasskeyAuthChallenge { challenge, requestId, expiresAt } IB-->>C: { challenge, requestId } C->>A: navigator.credentials.get({ challenge }) A-->>C: assertion C->>IB: POST /my-backend/passkey/reauth (assertion, requestId) IB->>G: POST /auth/credentials/{id}/verify
          Request-Id: requestId
          { type: PASSKEY, assertion } G-->>IB: 200 AuthSession { id, expiresAt } IB-->>C: { sessionId, expiresAt } ``` ## OAuth (OIDC) Use an OAuth credential when your platform already authenticates the user with an OpenID Connect identity provider (Google, Apple, your own IdP) and you want Grid to trust that same identity. Adding OAuth to an initialized Global Account requires an active session from an existing credential and uses the same signed-retry pattern as passkey registration. ### OAuth registration ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid participant OP as OIDC provider C->>OP: Sign-in flow OP-->>C: id_token (OIDC) C->>IB: POST /my-backend/oauth/register { oidcToken } IB->>G: POST /auth/credentials { type: OAUTH, oidcToken, accountId } G-->>IB: 202 { payloadToSign, requestId, expiresAt } IB-->>C: { payloadToSign, requestId } C->>C: stamp(payloadToSign, existingSessionPrivateKey) C->>IB: { stamp, requestId } IB->>G: Same POST
          Grid-Wallet-Signature: stamp
          Request-Id: requestId G->>OP: fetch .well-known/openid-configuration + jwks G-->>IB: 201 AuthMethod IB-->>C: { credentialId } C->>C: generateClientKeyPair() C->>OP: get fresh id_token (iat within 60s) OP-->>C: id_token C->>IB: POST /my-backend/oauth/verify { oidcToken, clientPublicKey } IB->>G: POST /auth/credentials/{id}/verify { type: OAUTH, oidcToken, clientPublicKey } G-->>IB: 200 AuthSession IB-->>C: { sessionId, expiresAt } ``` Grid validates the OIDC token signature against the issuer's JWKS on every call and requires `iat` to be no more than **60 seconds** older than the request. Use a fresh token for each `verify` call; cached tokens will fail. The token identity (`iss`, `aud`, and `sub`) must match the OAuth credential being verified. > [!NOTE] > > In sandbox, OAuth still uses JWT-shaped OIDC tokens. The sandbox skips real IdP signature verification, but it validates the same identity and freshness claims. For `verify`, include `nonce` equal to the lowercase hex SHA-256 digest of the `clientPublicKey` string exactly as sent: the compressed key in lowercase hex. See [Sandbox testing](/global-accounts/platform-tools/sandbox-testing#oauth-oidc-token). ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "OAUTH", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9..." }' ``` **Response on the signed retry:** `201 AuthMethod` with `nickname` populated from the OIDC token's `email` claim. ### OAuth verify / reauthentication `POST /auth/credentials/{id}/verify` is also the reauthentication path — call it with a fresh OIDC token whenever the session expires. ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "OAUTH", "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9...", "clientPublicKey": "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" }' ``` ## Email OTP The lowest-friction credential type — works on any device with email access and requires no biometric hardware, identity provider, or client-side setup beyond an input field for the code. Global Accounts are initialized with an `EMAIL_OTP` credential; call `POST /auth/credentials` for `EMAIL_OTP` only if the credential was removed and you need to add it back. ### Default Email OTP credential Grid creates the first `EMAIL_OTP` credential when the Global Account is provisioned. The credential uses the customer email on file for the internal account. To authenticate with it, send an OTP challenge, then verify using the secure encrypted OTP flow. The client never sends the plaintext OTP code. Instead, it HPKE-encrypts the code (together with a fresh public key) to an enclave bundle returned from the challenge. The server is a pass-through and never sees the plaintext. ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid participant E as Email C->>IB: POST /my-backend/otp/challenge { credentialId } IB->>G: POST /auth/credentials/{id}/challenge G->>E: deliver OTP email (to customer email on file) G-->>IB: 200 AuthMethod + otpEncryptionTargetBundle IB-->>C: { otpEncryptionTargetBundle } E-->>C: OTP code C->>C: generateClientKeyPair() (TEK) C->>C: HPKE-encrypt { otp_code, public_key } → encryptedOtpBundle C->>IB: POST /my-backend/otp/verify { encryptedOtpBundle } IB->>G: POST /auth/credentials/{id}/verify { type: EMAIL_OTP, encryptedOtpBundle } G-->>IB: 202 { payloadToSign, requestId } IB-->>C: { payloadToSign, requestId } C->>C: sign(payloadToSign, tekPrivateKey) C->>IB: POST /my-backend/otp/verify/complete { stamp, requestId } IB->>G: Same POST + Grid-Wallet-Signature + Request-Id G-->>IB: 200 AuthSession IB-->>C: { expiresAt } ``` ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/challenge" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (200):** ```json theme={null} { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000004", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "otpEncryptionTargetBundle": "{\"version\":\"v1.0.0\",\"data\":\"7b22...\",\"dataSignature\":\"3045...\",\"enclaveQuorumPublic\":\"04a1...\"}", "createdAt": "2026-04-19T12:00:00Z", "updatedAt": "2026-04-19T12:00:00Z" } ``` The client generates a fresh P-256 key pair (the TEK — Target Encryption Key), HPKE-encrypts `{otp_code, public_key}` under `otpEncryptionTargetBundle`, and submits the encrypted payload. `public_key` must be the compressed public key (`02` or `03` prefix). See [Encrypt the OTP code](client-keys#encrypt-the-otp-code-email_otp-only) for implementation details. Then verify with the encrypted OTP bundle: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "EMAIL_OTP", "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" }' ``` **Response (202):** ```json theme={null} { "type": "EMAIL_OTP", "payloadToSign": "{\"login\":{\"publicKey\":\"02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31\"},\"tokenId\":\"7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21\",\"type\":\"USAGE_TYPE_LOGIN\"}", "requestId": "Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21", "expiresAt": "2026-04-19T12:05:00Z" } ``` The client stamps the exact UTF-8 bytes of `payloadToSign`, unchanged, with the TEK private key (the same key whose public key was encrypted in the bundle), then retries the same request body with the stamp: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ -d '{ "type": "EMAIL_OTP", "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" }' ``` **Response (200):** ```json theme={null} { "id": "Session:019542f5-b3e7-1d02-0000-000000000003", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-19T12:00:01Z", "updatedAt": "2026-04-19T12:00:01Z", "expiresAt": "2026-04-19T12:15:01Z" } ``` The TEK public key becomes the session API key. The response carries no key material: the client already holds the session signing key (the TEK private key it generated). `verify` shares its terminal status code with the still-processing response: a `200` here is only a session if the body isn't `{ "status": "PROCESSING" }`. See [handling a still-processing response](#handling-a-still-processing-response) — if you get `PROCESSING`, re-send the identical signed retry (same `encryptedOtpBundle`, `Grid-Wallet-Signature`, and `Request-Id`) until you get the session shown above. > [!NOTE] > > **In sandbox, the OTP code is always `000000`** — encrypt that value in the bundle. The sandbox runs real HPKE end-to-end; the only shortcut is skipping email delivery. See [Client keys](client-keys#encrypt-the-otp-code-email_otp-only) for the encryption flow. ### Resending an OTP If the code expires or the email didn't arrive, re-issue the challenge with `POST /auth/credentials/{id}/challenge`. This sends a fresh OTP email and leaves the `AuthMethod` otherwise untouched. `challenge` can also return a [still-processing `200`](#handling-a-still-processing-response) while the resend settles at the wallet provider — re-request the challenge until you get the `AuthMethod` back rather than a fresh OTP being sent twice. ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/challenge" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [!WARNING] > > Challenge re-issues are rate-limited. On `429`, back off for the duration returned in the `Retry-After` header before retrying. ### Email OTP reauthentication Same pattern as the first activation: call `/challenge` to send a new OTP and receive a fresh `otpEncryptionTargetBundle`, generate a new TEK key pair, build the `encryptedOtpBundle`, and complete the two-step verify flow. ### Changing the sign-in email or phone number The `EMAIL_OTP` address is the customer email on file, and the `SMS_OTP` number is the customer phone number. The customer must prove they control a new address or number before it replaces the one they sign in with. Changing it takes four calls, and the same steps apply to `phoneNumber` with `SMS_OTP`. **1. Register the new address** Call `POST /auth/credentials` with the new address. Grid sends a code to it and returns the replacement credential with an `otpEncryptionTargetBundle`. The current credential keeps working until the change lands. ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "EMAIL_OTP", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "email": "jane.new@example.com" }' ``` **2. Verify the code** Encrypt the code the customer received to that `otpEncryptionTargetBundle`, as for a login, and send it to `POST /auth/credentials/{id}/verify` with the replacement's id. Grid answers `204` with no session. A replacement that is not verified within 24 hours expires. **3. Update the customer** Call `PATCH /customers/{customerId}` with only `customerType` and the new address. Grid answers `202` with `payloadToSign`, `requestId`, and `expiresAt`. Make this call soon after verifying. If the verification would lapse before the challenge does, Grid returns `400 AUTH_CREDENTIAL_VERIFICATION_EXPIRED`, and the customer must verify again. ```bash theme={null} curl -X PATCH "$GRID_BASE_URL/customers/Customer:019542f5-b3e7-1d02-0000-000000000001" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerType": "INDIVIDUAL", "email": "jane.new@example.com" }' ``` **4. Signed retry** The client stamps `payloadToSign` with an active session signing key on the same account. Retry the identical request with `Grid-Wallet-Signature` and `Request-Id`. Grid answers `200` with the updated customer, and the new address becomes the account's `EMAIL_OTP` credential in place of the old one. A `200` with `{ "status": "PROCESSING" }` means the change is still landing: re-send the identical signed request until it returns the customer. A `PATCH` that changes the sign-in address without a verified replacement returns `400 AUTH_CREDENTIAL_VERIFICATION_REQUIRED`. The change must be sent on its own, so a request that also changes other customer fields returns `400 INVALID_INPUT`. Customers without an `EMAIL_OTP` or `SMS_OTP` credential of that type change `email` or `phoneNumber` with a plain update. ## Managing credentials Every Global Account starts with a single `EMAIL_OTP` credential — the one used in the [quickstart](overview#quickstart). In production, encourage customers to register a backup credential, such as a passkey or OAuth credential, so the account is recoverable if their primary device is lost. Adding, revoking, and rotating credentials after the first all go through the same **two-step signed-retry** pattern. ### List credentials ```bash theme={null} curl -X GET "$GRID_BASE_URL/auth/credentials?accountId=InternalAccount:019542f5-b3e7-1d02-0000-000000000002" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (200):** ```json theme={null} { "data": [ { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000001", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "PASSKEY", "credentialId": "KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Pxg6jo_o0hYiew", "nickname": "iPhone Face-ID", "createdAt": "2026-04-08T15:30:01Z", "updatedAt": "2026-04-08T15:30:01Z" }, { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000004", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-09T10:15:00Z", "updatedAt": "2026-04-09T10:15:00Z" } ] } ``` The response is not paginated — each account holds a small, bounded number of credentials. ### The signed-retry pattern Adding an additional credential, revoking a credential, refreshing or revoking a session, exporting a wallet, updating wallet privacy, and changing a sign-in email or phone number all share the same shape: ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid IB->>G: Request (no headers) G-->>IB: 202 { payloadToSign, requestId, expiresAt } IB-->>C: { payloadToSign, requestId } C->>C: stamp(payloadToSign, sessionPrivateKey) C->>IB: { stamp } IB->>G: Same request
          Grid-Wallet-Signature: stamp
          Request-Id: requestId alt still settling at the wallet provider G-->>IB: 200 { status: "PROCESSING" } IB->>G: Re-send the identical signed request end G-->>IB: 2xx (terminal success) IB-->>C: done ``` Key rules: * Always stamp the `payloadToSign` **byte-for-byte as Grid returned it**. Do not re-parse, re-serialize, or modify whitespace. * Sign with the **session private key** held on the client — never ship it back to your backend. * The retry must reach Grid before `expiresAt` (typically 5 minutes from issue). * The `requestId` is returned as `Request:` and is single-use; reusing one yields `401`. ### Handling a still-processing response The signed retry can come back `200` with a `WalletOperationProcessing` body instead of the terminal success shown above: ```json theme={null} { "status": "PROCESSING", "message": "This login is still being processed. Retry the same request in a moment." } ``` This means Grid's wallet provider accepted the operation but hasn't settled it yet — the request is not lost, and nothing failed. **Design for this as the normal path, not a rare edge case**: handle it the same way you handle the terminal success and error responses below, not as a bolted-on afterthought. Every signed-retry endpoint can return it on the retry step: | Endpoint | Action | | - | - | | `POST /auth/credentials` | Add a credential | | `POST /auth/credentials/{id}/challenge` | Re-send an OTP / re-issue a challenge | | `POST /auth/credentials/{id}/verify` | Log in / verify a credential | | `POST /auth/sessions/{id}/refresh` | Refresh a session | | `DELETE /auth/credentials/{id}` | Revoke a credential | | `DELETE /auth/sessions/{id}` | Revoke a session | | `POST /internal-accounts/{id}/export` | Export wallet credentials | | `PATCH /customers/{customerId}` | Change a sign-in email or phone number | To handle it: 1. **Re-send the exact same signed request** — identical body, `Grid-Wallet-Signature`, and `Request-Id`. Don't regenerate the stamp or start a fresh request; Grid correlates the retry with the same underlying operation and returns its real outcome once the provider settles, rather than starting a second one. 2. **Show a pending state in your UI** while you retry — "removing…" for a credential revoke, "signing in…" for a login — instead of treating `PROCESSING` as an error or leaving the caller blocked with no feedback. 3. **Keep retrying with backoff** until you get a terminal response — the success code above, or an error. Grid also reconciles the operation to its terminal state on its own, so even a client that stops retrying and checks back later (for example via `GET /auth/credentials` or `GET /auth/sessions`) will see the settled result. **Wallet export is the exception**: Grid never stores the encrypted credentials, so re-sending the signed retry is the only way to receive them. Grid's own reconciliation settles the operation but has nothing to hand back, and the export challenge expires like any other (typically 5 minutes) — if it lapses, start a fresh export. > [!NOTE] > > `verify`, `challenge`, `export`, and the customer update already use `200` for their terminal success body (`AuthSession`, the challenge response, the encrypted credentials, or the customer). For those four, `200` alone doesn't tell you which case you're in — check the response body's `status` field for `"PROCESSING"` to distinguish it from a settled success. The other four endpoints (`add credential`, `session refresh`, `revoke credential`, `revoke session`) use `201` or `204` on success, so a `200` by itself already means still-processing. ### Add an additional credential Requires an active session on an *existing* credential on the same account. The first call uses the normal credential-create body; Grid detects the pre-existing credential and responds `202` instead of `201`. `OAUTH` and `PASSKEY` are the typical additional credential types. `EMAIL_OTP` can be added back only after the existing email OTP credential has been removed, because each account supports one. **1. First call — receive the challenge** ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "OAUTH", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9..." }' ``` **Response (202):** ```json theme={null} { "type": "OAUTH", "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"oauthProviders\":[{\"oidcToken\":\"eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9...\",\"providerName\":\"Google\"}],\"userId\":\"user_2m9F...\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_CREATE_OAUTH_PROVIDERS\"}", "requestId": "Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21", "expiresAt": "2026-04-08T15:35:00Z" } ``` **2. Client stamps the payload** Send `payloadToSign` to the client. The client builds a Grid wallet signature with the session signing key from the existing credential's active session — see [signing payloads](client-keys#2-sign-a-payloadtosign). **3. Signed retry — credential is created** Re-run the same request with the stamp and request id in headers: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ -d '{ "type": "OAUTH", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9..." }' ``` **Response (201):** a plain `AuthMethod`. If the underlying wallet-provider activity is still settling, this returns `200 { status: "PROCESSING" }` instead — see [handling a still-processing response](#handling-a-still-processing-response); re-send the identical signed request until you get the `201`. **4. Activate the new credential** Activate the new credential the same way you would activate the first credential of that type — `OAUTH` goes straight to `POST /auth/credentials/{id}/verify` with a fresh `clientPublicKey`; `EMAIL_OTP` uses the `otpEncryptionTargetBundle` from the signed-retry registration response when present, or first calls `POST /auth/credentials/{id}/challenge` if the bundle is absent; `PASSKEY` first calls `POST /auth/credentials/{id}/challenge` with the `clientPublicKey` to get a Grid-issued WebAuthn challenge, then `POST /auth/credentials/{id}/verify` with the assertion and the `Request-Id` header. > [!NOTE] > > The account holder may have one `EMAIL_OTP` credential per internal account; an administrator's credentials, added with `POST /auth/admin-credentials`, are not counted. An administrator's session cannot add credentials with `POST /auth/credentials` (`403 FORBIDDEN`); administrators add their own with `POST /auth/admin-credentials`. Multiple distinct `PASSKEY` credentials are allowed; registering the same WebAuthn credentialId twice returns `400 PASSKEY_CREDENTIAL_ALREADY_EXISTS`. ### Revoke a credential A credential is revoked by signing with a session from **a different credential on the same account**. This prevents a compromised credential from revoking itself to lock the legitimate owner out. An account must keep at least one credential — if only one exists, the revoke call returns `400`. **1. First call — receive the challenge** ```bash theme={null} curl -X DELETE "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (202):** ```json theme={null} { "type": "PASSKEY", "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"authenticatorIds\":[\"authenticator_2m9F...\"],\"userId\":\"user_2m9F...\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_DELETE_AUTHENTICATORS\"}", "requestId": "Request:9f7a2c10-5e88-4fb1-bd0e-1c3a8e7b2d45", "expiresAt": "2026-04-08T15:35:00Z" } ``` **2. Client stamps with a different credential's session** The client stamps `payloadToSign` with the session signing key of an active session on any *other* credential (not the one being revoked). **3. Signed retry — credential is revoked** ```bash theme={null} curl -X DELETE "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:9f7a2c10-5e88-4fb1-bd0e-1c3a8e7b2d45" ``` **Response:** `204 No Content`. All active sessions issued by the revoked credential are also revoked. If the revocation is still settling at the wallet provider, this returns `200 { status: "PROCESSING" }` instead — [re-send the identical `DELETE`](#handling-a-still-processing-response) until you get the `204`, and show the credential as "removing…" in your UI in the meantime rather than assuming success or failure. # Client keys & signing Source: https://agents-docs.lightspark.com/payouts-and-b2b/global-accounts/client-keys Generate the P-256 client key pair, hold the session signing key, and sign account actions on Web, iOS, and Android Every signed Global Account action uses two key pairs: | Key pair | Where it lives | What it does | | - | - | - | | **Client key pair** (P-256) | On the customer's device, generated fresh per session-issuing or export request | For authentication and session refresh, its private key becomes the session signing key. For wallet export, its public key is the recipient key Grid encrypts the wallet credentials to. Ephemeral — one pair per authentication, session refresh, or wallet export. | | **Session signing key** (P-256) | Held on the customer's device | Signs every account action for the lifetime of the session (default 15 minutes). It is the private key of the client key pair sent on the request that issued the session. | This page covers generating the client key pair, sending the public key to your backend, holding the session signing key, and signing payloads. Everything here runs **on the client**; your integrator backend only relays opaque byte strings. ## 1. Generate a client key pair The key pair you generate here *is* your session key: you keep the private key on the device and use it directly to sign account actions. Grid never transmits a session key, so session-issuing responses carry no key material. Generate it as a P-256 **signing** key (ECDSA). Generate a fresh key pair for every authentication and session refresh. Where the public key goes depends on the flow: * `PASSKEY`: send it as `clientPublicKey` on `POST /auth/credentials/{id}/challenge`. * `OAUTH`: send it as `clientPublicKey` on `POST /auth/credentials/{id}/verify`. * `EMAIL_OTP`: put it inside `encryptedOtpBundle` on `POST /auth/credentials/{id}/verify`. See [Encrypt the OTP code](#encrypt-the-otp-code-email_otp-only). * Session refresh: send it as `clientPublicKey` on both `/auth/sessions/{id}/refresh` calls. Wallet export uses a different kind of key pair. See [Wallet export key pair](#wallet-export-key-pair). Keep the private key in device-local secure storage (iOS Keychain, Android Keystore, or in the browser, `IndexedDB` encrypted under a non-extractable Web Crypto key). Send the public key through your integrator backend as a **compressed** SEC1 point in **lowercase** hex: 66 characters, a `02` or `03` prefix followed by the 32-byte X coordinate. Grid rejects an uppercase or uncompressed key with `400 INVALID_INPUT`. > [!TIP] > > For local development, you can generate a P-256 key pair from the command line: > > ```bash theme={null} > # Private key (PKCS#8 PEM) > openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out private.pem > > # Compressed public key as lowercase hex > openssl ec -in private.pem -pubout -conv_form compressed -outform DER 2>/dev/null \ > | tail -c 33 | xxd -p -c 33 > ``` ```typescript Web (TypeScript) theme={null} // npm i @noble/curves import { p256 } from "@noble/curves/nist.js"; function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } // Generate a P-256 signing key pair. The 32-byte private key becomes the // session signing key, so store it encrypted at rest on the device. function generateClientKeyPair(): { privateKey: Uint8Array; publicKeyHex: string; } { const privateKey = p256.utils.randomSecretKey(); // Compressed SEC1 form: 33 bytes, 0x02 or 0x03 followed by X. const publicKeyHex = bytesToHex(p256.getPublicKey(privateKey, true)); return { privateKey, publicKeyHex }; } ``` ```kotlin Android (Kotlin) theme={null} import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import java.math.BigInteger import java.security.KeyPairGenerator import java.security.interfaces.ECPublicKey import java.security.spec.ECGenParameterSpec data class ClientKeyPair(val alias: String, val publicKeyHex: String) private fun BigInteger.toFixed32(): ByteArray { val bytes = toByteArray() return ByteArray(32).also { out -> val copyFrom = maxOf(0, bytes.size - 32) val copyLength = bytes.size - copyFrom System.arraycopy(bytes, copyFrom, out, 32 - copyLength, copyLength) } } fun generateClientKeyPair(alias: String): ClientKeyPair { val generator = KeyPairGenerator.getInstance( KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore", ) generator.initialize( KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN) .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) .setDigests(KeyProperties.DIGEST_SHA256) .build(), ) val point = (generator.generateKeyPair().public as ECPublicKey).w // Compressed SEC1 form: 0x02 for an even Y, 0x03 for an odd Y, then X. val prefix = if (point.affineY.testBit(0)) 0x03.toByte() else 0x02.toByte() val compressed = byteArrayOf(prefix) + point.affineX.toFixed32() val hex = compressed.joinToString("") { "%02x".format(it) } return ClientKeyPair(alias = alias, publicKeyHex = hex) } ``` ```swift iOS (Swift) theme={null} import CryptoKit struct ClientKeyPair { let privateKey: P256.Signing.PrivateKey let publicKeyHex: String } func generateClientKeyPair() -> ClientKeyPair { let privateKey = P256.Signing.PrivateKey() // compressedRepresentation is the 33-byte SEC1 form (0x02 or 0x03, then X). let raw = privateKey.publicKey.compressedRepresentation let hex = raw.map { String(format: "%02x", $0) }.joined() return ClientKeyPair(privateKey: privateKey, publicKeyHex: hex) } ``` > [!NOTE] > > The private key **must not leave the device**. Your integrator backend only ever sees `publicKeyHex`. ### Wallet export key pair Wallet export encrypts the wallet credentials to the key you send, so it needs a P-256 **key agreement** (ECDH) key pair rather than a signing key. Generate a fresh one for each export and discard the private key after decrypting. Send its public key as `clientPublicKey` on both `/export` calls, as an **uncompressed** SEC1 point in **lowercase** hex: 130 characters, a `04` prefix followed by the 32-byte X and 32-byte Y coordinates. The samples below produce that form. See [Exporting a wallet](exporting-wallet) for the full flow. ```typescript Web (TypeScript) theme={null} function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } // Generate a non-extractable P-256 key pair in the browser. // The private key never leaves Web Crypto; only the public key is exported. async function generateExportKeyPair(): Promise<{ keyPair: CryptoKeyPair; publicKeyHex: string; }> { const keyPair = await crypto.subtle.generateKey( { name: "ECDH", namedCurve: "P-256" }, false, // private key non-extractable ["deriveBits"], ); const raw = new Uint8Array( await crypto.subtle.exportKey("raw", keyPair.publicKey), ); // exportKey("raw") returns the 65-byte uncompressed form (0x04 || X || Y). const publicKeyHex = bytesToHex(raw); return { keyPair, publicKeyHex }; } ``` ```kotlin Android (Kotlin) theme={null} import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import java.math.BigInteger import java.security.KeyPairGenerator import java.security.interfaces.ECPublicKey import java.security.spec.ECGenParameterSpec data class ExportKeyPair(val alias: String, val publicKeyHex: String) // toFixed32() is the helper from the client key pair sample above. fun generateExportKeyPair(alias: String): ExportKeyPair { val generator = KeyPairGenerator.getInstance( KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore", ) generator.initialize( KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_AGREE_KEY) .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) .build(), ) val point = (generator.generateKeyPair().public as ECPublicKey).w val uncompressed = byteArrayOf(0x04) + point.affineX.toFixed32() + point.affineY.toFixed32() val hex = uncompressed.joinToString("") { "%02x".format(it) } return ExportKeyPair(alias = alias, publicKeyHex = hex) } ``` ```swift iOS (Swift) theme={null} import CryptoKit struct ExportKeyPair { let privateKey: P256.KeyAgreement.PrivateKey let publicKeyHex: String } func generateExportKeyPair() -> ExportKeyPair { let privateKey = P256.KeyAgreement.PrivateKey() // x963Representation returns uncompressed SEC1 (65 bytes starting with 0x04). let raw = privateKey.publicKey.x963Representation let hex = raw.map { String(format: "%02x", $0) }.joined() return ExportKeyPair(privateKey: privateKey, publicKeyHex: hex) } ``` ## Encrypt the OTP code (`EMAIL_OTP` only) `EMAIL_OTP` credentials never send the OTP code in plaintext. Instead, the client HPKE-encrypts the code, together with its public key, to an enclave key, so the code is unreadable in transit and Grid is only a pass-through. Grid returns an `otpEncryptionTargetBundle` whenever it initiates or reissues an OTP challenge, including `POST /auth/credentials/{id}/challenge` and add-EMAIL\_OTP signed-retry responses. First-time EMAIL\_OTP wallet bootstrap registration can omit it; if the registration response has no bundle, call `POST /auth/credentials/{id}/challenge` for that credential before verifying. The bundle is a signed enclave bundle whose `data` field is hex-encoded JSON carrying the enclave's HPKE target key as `targetPublic`. Pull out `targetPublic`, HPKE-encrypt `{ otp_code, public_key }` to it, and submit the library's `{ encappedPublic, ciphertext }` output as `encryptedOtpBundle` on `POST /auth/credentials/{id}/verify`. `public_key` must be the **compressed** public key in lowercase hex: 66 characters starting with `02` or `03`. The step 1 samples produce this form. The OTP login challenge is bound to this exact key, so the same compressed hex must be the `publicKey` in the `Grid-Wallet-Signature` stamp on the signed retry (see [step 2](#2-sign-a-payloadtosign)); a different value there fails the login. In production, an uncompressed key makes the enclave reject the OTP code, even when the code is correct. The first verify call then returns: ```json theme={null} {"code": "INVALID_INPUT", "reason": "Invalid wallet request."} ``` Grid returns this same response for other rejected requests too, including a mistyped OTP code. If you get it with a code you know is correct, check the key format first. Use an HPKE library so you don't hand-roll the suite, `info`, or AAD. The helper below assumes your crypto layer returns the JSON string Grid expects for `encryptedOtpBundle`. ```typescript Web (TypeScript) theme={null} // npm i @noble/hashes import { hexToBytes } from "@noble/hashes/utils.js"; declare function hpkeEncryptToGridBundle(args: { plainTextBuf: Uint8Array; targetKeyBuf: Uint8Array; }): string; // otpEncryptionTargetBundle: from the registration response when present, or /challenge // clientPublicKeyHex: the compressed public key from step 1 // otp: the code the user typed ("000000" in sandbox) function buildEncryptedOtpBundle( otpEncryptionTargetBundle: string, clientPublicKeyHex: string, otp: string, ): string { // Pull the enclave's target key out of the signed bundle. const { data } = JSON.parse(otpEncryptionTargetBundle) as { data: string }; const { targetPublic } = JSON.parse( new TextDecoder().decode(hexToBytes(data)), ) as { targetPublic: string }; // The enclave expects snake_case keys. const plainTextBuf = new TextEncoder().encode( JSON.stringify({ otp_code: otp, public_key: clientPublicKeyHex }), ); return hpkeEncryptToGridBundle({ plainTextBuf, targetKeyBuf: hexToBytes(targetPublic), }); } ``` > [!NOTE] > > A production client should also verify the bundle's `dataSignature` against its `enclaveQuorumPublic` before trusting `targetPublic`. The private key of the pair you generated in step 1 stays on the device. It becomes the session signing key once verification completes (see [Sign a `payloadToSign`](#2-sign-a-payloadtosign)). ## 2. Sign a `payloadToSign` Grid returns `payloadToSign` strings from several endpoints: * `POST /quotes` (when the source is a Global Account) — the quote's `paymentInstructions[].accountOrWalletInfo.payloadToSign`. * `POST /auth/credentials/{id}/verify` (`EMAIL_OTP` or `SMS_OTP`) — 202 response body; use the TEK private key generated for this login. * `POST /auth/credentials` (adding an additional credential) — 202 response body. * `DELETE /auth/credentials/{id}`, `DELETE /auth/sessions/{id}`, `POST /auth/sessions/{id}/refresh`, `POST /internal-accounts/{id}/export`, `PATCH /internal-accounts/{id}`, `PATCH /customers/{id}` for sign-in email or phone number changes — all 202 response bodies. Stamp the exact UTF-8 bytes of the `payloadToSign` string **as returned** (do not re-parse, re-serialize, or trim whitespace). For OTP verification, use the matching TEK private key; for other signed wallet actions, use an active session signing key. Build a Grid wallet signature: take its compressed P-256 public key in lowercase hex, sign the payload with the private scalar, then base64url-encode the signature envelope: ```json theme={null} { "publicKey": "", "scheme": "SIGNATURE_SCHEME_TK_API_P256", "signature": "" } ``` Pass that full stamp as the `Grid-Wallet-Signature` header on the retry (and, for endpoints that use it, echo the 202 `requestId` as `Request-Id`). > [!NOTE] > > Sign with real stamps in sandbox too. Sandbox verifies them exactly as production does. OTP verification requires a stamp over the returned `payloadToSign` with the TEK private key. ```typescript Web (TypeScript) theme={null} declare function deriveCompressedP256PublicKey(privateKeyHex: string): string; declare function signP256Sha256Der(args: { content: string; privateKeyHex: string; }): Promise; const GRID_STAMP_SCHEME = "SIGNATURE_SCHEME_TK_API_P256"; function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } function base64url(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) { binary += String.fromCharCode(byte); } return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } async function buildGridWalletSignature( sessionPrivateKeyBytes: Uint8Array, // 32 bytes, the step 1 private key payloadToSign: string, ): Promise { const apiPrivateKey = bytesToHex(sessionPrivateKeyBytes); const apiPublicKey = deriveCompressedP256PublicKey(apiPrivateKey); const signature = await signP256Sha256Der({ content: payloadToSign, privateKeyHex: apiPrivateKey, }); const stamp = JSON.stringify({ publicKey: apiPublicKey, scheme: GRID_STAMP_SCHEME, signature, }); return base64url(new TextEncoder().encode(stamp)); } ``` ```kotlin Android (Kotlin) theme={null} import android.util.Base64 import java.security.KeyStore import java.security.PrivateKey import java.security.Signature private const val GRID_STAMP_SCHEME = "SIGNATURE_SCHEME_TK_API_P256" private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } private fun base64url(value: String): String = Base64.encodeToString( value.toByteArray(Charsets.UTF_8), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP, ) fun buildGridWalletSignature( sessionKeys: ClientKeyPair, // from generateClientKeyPair in step 1 payloadToSign: String, ): String { val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } val privateKey = keyStore.getKey(sessionKeys.alias, null) as PrivateKey val signer = Signature.getInstance("SHA256withECDSA").apply { initSign(privateKey) update(payloadToSign.toByteArray(Charsets.UTF_8)) } val derSignatureHex = signer.sign().hex() // JCE returns DER-encoded ECDSA. val stampJson = """{"publicKey":"${sessionKeys.publicKeyHex}","scheme":"$GRID_STAMP_SCHEME","signature":"$derSignatureHex"}""" return base64url(stampJson) } ``` ```swift iOS (Swift) theme={null} import CryptoKit import Foundation func buildGridWalletSignature( signingKey: P256.Signing.PrivateKey, // from generateClientKeyPair in step 1 payloadToSign: String, ) throws -> String { let payload = Data(payloadToSign.utf8) let signature = try signingKey.signature(for: payload) let signatureHex = signature.derRepresentation.map { String(format: "%02x", $0) }.joined() let publicKeyHex = signingKey.publicKey.compressedRepresentation .map { String(format: "%02x", $0) } .joined() let stampJson = #"{"publicKey":"\#(publicKeyHex)","scheme":"SIGNATURE_SCHEME_TK_API_P256","signature":"\#(signatureHex)"}"# return Data(stampJson.utf8) .base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } ``` Your backend adds the stamp to the retry request: ```bash theme={null} curl -X POST "https://api.lightspark.com/grid/2025-10-13/quotes/Quote:019542f5-b3e7-1d02-0000-000000000006/execute" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" ``` ## Session lifetime Sessions are valid for 15 minutes by default. The `AuthSession.expiresAt` field tells you exactly when the session signing key stops being accepted. After expiry, the client must re-verify the credential (see [Authentication](authentication)) to obtain a fresh session. > [!WARNING] > > If the device is lost or compromised, the user should add a second credential from a trusted device and revoke the compromised one — see [Managing credentials](authentication#managing-credentials). To end the current browser or app session without touching credentials, see [Sessions](managing-sessions). # Exporting a wallet Source: https://agents-docs.lightspark.com/payouts-and-b2b/global-accounts/exporting-wallet Let a customer take the seed of their Global Account off Grid Exporting a wallet returns the wallet's mnemonic seed, encrypted to the client's public key. The customer decrypts it on their device and can then import the wallet into any compatible self-custody client. Grid never sees the plaintext seed leaving the system. Export uses the same [signed-retry pattern](authentication#the-signed-retry-pattern) as credential and session revocation — the initial `POST` returns a `payloadToSign`, and the signed retry returns the encrypted seed. Generate a fresh P-256 key agreement key pair specifically for the export (see [Wallet export key pair](client-keys#wallet-export-key-pair)). Send its public key as `clientPublicKey` on both export requests, as an uncompressed key in lowercase hex (130 characters, `04` prefix), then decrypt `encryptedWalletCredentials` with the matching private key after the signed retry succeeds. ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid IB->>G: POST /internal-accounts/{id}/export { clientPublicKey } G-->>IB: 202 { payloadToSign, requestId, expiresAt } IB-->>C: { payloadToSign, requestId } C->>C: stamp(payloadToSign, sessionPrivateKey) C->>IB: { stamp } IB->>G: POST /internal-accounts/{id}/export { same clientPublicKey }
          Grid-Wallet-Signature
          Request-Id alt still settling at the wallet provider G-->>IB: 200 { status: "PROCESSING" } IB->>G: Re-send the identical signed request end G-->>IB: 200 { id, encryptedWalletCredentials } IB-->>C: { encryptedWalletCredentials } C->>C: decrypt with client private key
          → mnemonic ``` **1. First call — receive the challenge** ```bash theme={null} curl -X POST "$GRID_BASE_URL/internal-accounts/InternalAccount:019542f5-b3e7-1d02-0000-000000000002/export" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "clientPublicKey": "04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2" }' ``` **Response (202):** ```json theme={null} { "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"targetPublicKey\":\"04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2\",\"walletId\":\"wallet_2m9F...\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_EXPORT_WALLET\"}", "requestId": "Request:c3f8a614-47e2-4a19-9f5d-2b0a91d47e08", "expiresAt": "2026-04-19T12:10:00Z" } ``` **2. Client stamps the payload** Build a Grid wallet signature over `payloadToSign` with an active session signing key on the account. Keep the export private key on the client; Grid will use the matching `clientPublicKey` from step 1 to seal the wallet credentials. **3. Signed retry — receive the encrypted seed** ```bash theme={null} curl -X POST "$GRID_BASE_URL/internal-accounts/InternalAccount:019542f5-b3e7-1d02-0000-000000000002/export" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:c3f8a614-47e2-4a19-9f5d-2b0a91d47e08" \ -d '{ "clientPublicKey": "04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2" }' ``` **Response (200):** ```json theme={null} { "id": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "encryptedWalletCredentials": "{\"version\":\"v1.0.0\",\"data\":\"7b22656e6361707065645075626c6963223a2230346634356632612e2e2e222c2263697068657274657874223a22316661313032333339302e2e2e222c226f7267616e697a6174696f6e4964223a226f72675f326d39462e2e2e227d\",\"dataSignature\":\"3045022100...\",\"enclaveQuorumPublic\":\"04a1b2c3...\"}" } ``` Export shares its terminal status code with the still-processing response: a `200` here is only the encrypted seed if the body isn't `{ "status": "PROCESSING" }`. See [handling a still-processing response](authentication#handling-a-still-processing-response) — if you get `PROCESSING`, re-send the identical signed retry (same `clientPublicKey`, `Grid-Wallet-Signature`, and `Request-Id`) until you get the credentials. Because Grid never stores them, the re-send is the only way to receive them; subscribe to `wallet_operation.completed` to know when it will succeed. The `Request-Id` challenge is consumed only by the attempt that actually returns credentials, so a `PROCESSING` response leaves it usable. **4. Decrypt on the client** `encryptedWalletCredentials` is a signed wallet export envelope. Parse the envelope, verify `dataSignature` against `enclaveQuorumPublic`, decode the hex `data` JSON to get `encappedPublic` and `ciphertext`, then decrypt with the export private key that matches the `clientPublicKey` you sent on both export requests. The plaintext is a BIP-39 mnemonic (the wallet's master seed). > [!WARNING] > > The exported mnemonic is the master key of the self-custody wallet. After decryption the customer is the only custodian — if the mnemonic is lost, the funds are lost. Surface appropriate warnings in your UI before running an export. # Sessions Source: https://agents-docs.lightspark.com/payouts-and-b2b/global-accounts/managing-sessions List and revoke active sessions on a Global Account Every call to `POST /auth/credentials/{id}/verify` creates a new **session** — an authenticated signing context with a 15-minute lifetime by default. Sessions accumulate: a customer signed in on a laptop and a phone has two active sessions, each with its own session signing key held on that device. Use the session endpoints to show active sign-ins, refresh an active session before it expires, and sign out of a specific device. ## List active sessions ```bash theme={null} curl -X GET "$GRID_BASE_URL/auth/sessions?accountId=InternalAccount:019542f5-b3e7-1d02-0000-000000000002" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (200):** ```json theme={null} { "data": [ { "id": "Session:019542f5-b3e7-1d02-0000-000000000003", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "PASSKEY", "nickname": "iPhone Face-ID", "createdAt": "2026-04-19T12:00:02Z", "updatedAt": "2026-04-19T12:00:02Z", "expiresAt": "2026-04-19T12:15:02Z" }, { "id": "Session:019542f5-b3e7-1d02-0000-000000000007", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-19T10:01:00Z", "updatedAt": "2026-04-19T10:01:00Z", "expiresAt": "2026-04-19T10:16:00Z" } ] } ``` The list endpoint returns all **active** sessions; expired sessions are not included. Session responses never carry the session signing key. The client generated it and already holds it. ## Refresh a session Session refresh issues a new session for a new session signing key, authorized by an existing active session. Generate a fresh client key pair and send its compressed public key in lowercase hex as `clientPublicKey` (see [Generate a client key pair](client-keys#1-generate-a-client-key-pair)). Use this when the customer is still present and the current session is close to expiration. If the session has already expired, reauthenticate with the original credential instead. Refresh uses the same [signed-retry pattern](authentication#the-signed-retry-pattern) as credential management, including the possibility of a [still-processing `200` response](authentication#handling-a-still-processing-response) on the signed retry — re-send the same request until you get the `201` below. **1. First call — receive the challenge** ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/sessions/Session:019542f5-b3e7-1d02-0000-000000000003/refresh" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "clientPublicKey": "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" }' ``` **Response (202):** ```json theme={null} { "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"expirationSeconds\":\"900\",\"publicKey\":\"02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_STAMP_LOGIN\"}", "requestId": "Request:8c1e7f55-7b9c-4383-86c7-0cbde77c7328", "expiresAt": "2026-04-19T12:10:00Z" } ``` **2. Client stamps the payload** Build a Grid wallet signature over `payloadToSign` with the current session signing key. **3. Signed retry — receive the refreshed session** ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/sessions/Session:019542f5-b3e7-1d02-0000-000000000003/refresh" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:8c1e7f55-7b9c-4383-86c7-0cbde77c7328" \ -d '{ "clientPublicKey": "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" }' ``` **Response (201):** the new `AuthSession`. The private key matching the `clientPublicKey` above is now the session signing key, so replace the old one on the client. ## Revoke a session Session revocation uses the same [signed-retry pattern](authentication#the-signed-retry-pattern) as credential management, including a possible [still-processing `200` response](authentication#handling-a-still-processing-response) on the signed retry. Unlike credential revocation, a session **can revoke itself** — this is how self-logout works: sign with the session key you are about to invalidate. **1. First call — receive the challenge** ```bash theme={null} curl -X DELETE "$GRID_BASE_URL/auth/sessions/Session:019542f5-b3e7-1d02-0000-000000000003" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (202):** ```json theme={null} { "type": "PASSKEY", "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"apiKeyIds\":[\"api_key_2m9F...\"],\"userId\":\"user_2m9F...\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_DELETE_API_KEYS\"}", "requestId": "Request:2b1e5a08-9c44-4e91-ae7f-6d0b3f8c1e22", "expiresAt": "2026-04-19T12:10:00Z" } ``` **2. Client stamps the payload** Build a Grid wallet signature over `payloadToSign` with any active session signing key on the same account — either the session being revoked (self-logout) or another session (admin-style sign-out of a different device). **3. Signed retry — session is revoked** ```bash theme={null} curl -X DELETE "$GRID_BASE_URL/auth/sessions/Session:019542f5-b3e7-1d02-0000-000000000003" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:2b1e5a08-9c44-4e91-ae7f-6d0b3f8c1e22" ``` **Response:** `204 No Content`. > [!TIP] > > Revoking a session only invalidates the session signing key, not the credential that issued it. The next call to `POST /auth/credentials/{id}/verify` on that credential still works and issues a brand new session. # Global Accounts Source: https://agents-docs.lightspark.com/payouts-and-b2b/global-accounts/overview Self-custody stablecoin accounts that plug into Grid payment flows A Global Account is what we consider a programmable, self-custodial account for holding and moving money. Under the hood, it is an embedded [Spark](https://spark.money) wallet that Lightspark provisions for your customer. It can hold different stablecoins, including those denominated in local currencies, as well as other digital assets such as Bitcoin. In the API, a Global Account is an internal account with `type: "EMBEDDED_WALLET"`. It uses the same customer, quote, transaction, and webhook primitives as other accounts. Receiving funds works the same way, too. To move money out, the customer must authorize the transfer on their device, which signs the payment with a session signing key. ## Why a Global Account? * **Self-custodial.** Neither Lightspark nor your platform can move funds unilaterally. Outbound transfers require authorization from the customer's device. * **Stablecoin-denominated.** Balances can be held in stablecoins like [Brale-issued USDB](https://brale.xyz/stablecoins/USDB). You can also issue your own stablecoin and [request enablement in Grid](/api-reference/stablecoins/request-grid-enablement-for-a-stablecoin). Use the `/quotes` API to convert from fiat or withdraw through supported payment rails. * **Native.** Reuse the customer, internal-account, quote, transaction, and webhook primitives you already use for payouts or P2P. Global Accounts add authentication and signing at the account level. ## Payment flow Global Accounts use the same `/quotes` and `/quotes/{id}/execute` pattern as other payments. Outbound transfers also require a client signature. * **Incoming funds.** Funding an account works like any other internal account. Create a quote with the Global Account as the `destination`, execute it, and Grid converts the source currency into USDB and credits the account. Incoming funds do not require customer approval. * **Outgoing funds.** Withdrawals and transfers out require the customer to authorize them on their device. Grid returns a `payloadToSign` in the quote's `paymentInstructions`; the client signs those bytes with its session signing key and passes the base64 signature as the `Grid-Wallet-Signature` header on `/quotes/{id}/execute`. Only then does Grid release the funds. Sessions are short-lived (15 minutes by default) and bound to a specific device via the client key pair, so a stolen signature can't be replayed from a different device or after the session expires. Standard transaction webhooks fire throughout the lifecycle. See [Transaction lifecycle](/platform-overview/core-concepts/transaction-lifecycle). ## Architecture Three parties participate in every signed action: | Party | Role | | - | - | | **Client** | The customer's device (browser, iOS app, or Android app). Generates the client key pair, runs WebAuthn, holds the session signing key, and signs outbound requests. | | **Integrator backend** | Your server. Holds your Grid API credentials, brokers every call to Grid on behalf of the client, and issues WebAuthn challenges for initial passkey registration. | | **Grid** | Verifies auth credentials, issues sessions bound to the client's public key, and enforces that every account action is authorized. | The client **never** talks to Grid directly. Every request flows client → integrator backend → Grid. ## Auth credentials, client keys, and session signing keys Three distinct pieces of crypto collaborate to authorize actions on the Global Account (withdrawals, credential changes, session revocations, wallet exports, and wallet privacy updates): | Piece | Where it lives | How long it lives | What it proves | | - | - | - | - | | **Auth credential**: passkey, OIDC token, or email OTP | Registered on the account; the passkey itself lives on the authenticator, OIDC on your IdP, OTP in the user's inbox | Until the customer revokes it | *"I am the human who owns this account."* Used to authenticate the user at the start of each session. | | **Client key pair** (P-256) | Generated on the client device for each session-issuing or export request; private key stays in device-local secure storage | One authentication, session refresh, or wallet export | Binds a session or wallet export to the exact device that asked for it. For a session, its private key is the session signing key. For a wallet export, Grid encrypts the credentials to its public key, so only this device can decrypt them. | | **Session signing key** (P-256) | The private key of the client key pair that issued the session; held on the device for the session's lifetime | 15 minutes (default) | *"This specific account action was approved on an authenticated device."* Builds Grid wallet signatures over the `payloadToSign` Grid returns on quotes, credential changes, session refresh/revocation, wallet exports, sign-in email or phone number changes, and wallet privacy updates. | The flow is always the same: verify an auth credential with a fresh client key pair → hold its private key as the short-lived session signing key → build a Grid wallet signature over the `payloadToSign` bytes on the client → pass that signature as the `Grid-Wallet-Signature` header on the request that actually moves funds or changes account state. This applies to withdrawals, adding or removing credentials, refreshing or revoking sessions, exporting the wallet seed, changing the sign-in email or phone number, and updating wallet privacy. ## Prerequisites > [!WARNING] > > Customers who hold a Global Account must be KYC/KYB verified before any account funds can move from or to fiat rails. This quickstart picks up after KYC is complete. > > In sandbox, customers are automatically KYC approved on creation so you can skip straight to account setup. You also need: * A platform configured with `USDB` in its supported currencies. In sandbox, USDB is enabled by default alongside `USD` and `USDC`. * Sandbox or production API credentials with access to the `Embedded Wallet Auth` and `Internal Accounts` endpoints. ```bash theme={null} export GRID_BASE_URL="https://api.lightspark.com/grid/2025-10-13" export GRID_CLIENT_ID="YOUR_SANDBOX_CLIENT_ID" export GRID_CLIENT_SECRET="YOUR_SANDBOX_CLIENT_SECRET" ``` ## Walkthrough The walkthrough below is the happy path: create a customer, find the auto-provisioned account and its default email OTP credential, fund it, and withdraw to a bank account. Each step shows the HTTP request your integrator backend makes on behalf of the client. ### 1. Create a customer Create the customer record. A Global Account is provisioned automatically whenever a customer is created on a platform that has `USDB` in its supported currencies — you don't need to pass it on the customer. ```bash theme={null} curl -X POST "$GRID_BASE_URL/customers" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerType": "INDIVIDUAL", "platformCustomerId": "ind-9f84e0c2", "region": "US", "email": "jane@example.com", "fullName": "Jane Doe", "birthDate": "1990-01-15", "nationality": "US" }' ``` **Response:** `201 Created` with the new `Customer:...` id. In sandbox, the customer is KYC-approved immediately; in production you would now run them through the KYC / KYB flow before any funds can move. ### 2. Find the Global Account When a customer is created on a USDB-enabled platform, Grid automatically provisions a Global Account alongside their other internal accounts. Fetch it by filtering the customer's internal accounts by `type=EMBEDDED_WALLET`. ```bash theme={null} curl -X GET "$GRID_BASE_URL/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001&type=EMBEDDED_WALLET" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response:** ```json theme={null} { "data": [ { "id": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "type": "EMBEDDED_WALLET", "balance": { "amount": 0, "currency": { "code": "USDB", "name": "USDB", "decimals": 6 } }, "totalBalance": { "amount": 0, "currency": { "code": "USDB", "name": "USDB", "decimals": 6 } }, "fundingPaymentInstructions": [], "createdAt": "2026-04-19T12:00:00Z", "updatedAt": "2026-04-19T12:00:00Z" } ], "hasMore": false, "totalCount": 1 } ``` Hold onto the `InternalAccount:...` id — every auth credential is scoped to it. ### 3. Find the default email OTP credential Global Accounts are initialized with an `EMAIL_OTP` credential tied to the customer email on file. Fetch the auth methods for the account and keep the `AuthMethod:...` id for the signing step later in this walkthrough. ```bash theme={null} curl -X GET "$GRID_BASE_URL/auth/credentials?accountId=InternalAccount:019542f5-b3e7-1d02-0000-000000000002" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response:** ```json theme={null} { "data": [ { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000001", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-19T12:00:01Z", "updatedAt": "2026-04-19T12:00:01Z" } ] } ``` You can add passkeys or OAuth credentials later, but adding credentials is itself a signed action. Start with the default email OTP credential to mint the first session signing key. ### 4. Fund the Account Global Accounts behave like any other internal account on the way in — incoming funds do not need the customer's signature. In sandbox, use the sandbox funding endpoint to skip straight to a funded state: ```bash theme={null} curl -X POST "$GRID_BASE_URL/sandbox/internal-accounts/InternalAccount:019542f5-b3e7-1d02-0000-000000000002/fund" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "amount": 1000000000 }' ``` `amount` is in the smallest unit of the account's currency. USDB has 6 decimals, so `1000000000` is 1,000.00 USDB. You will receive an `INCOMING_PAYMENT` webhook when the balance updates. The account now holds 1,000.00 USDB. > [!TIP] > > To fund from another currency (USD ACH, USDC on-chain, etc.), create a quote with `destination.destinationType: "ACCOUNT"` pointing at the Global Account's `InternalAccount` id. The quote's `sourceCurrency` can be any supported platform currency; Grid will convert into USDB on execute. ### 5. Add an external bank account Add the destination the customer wants to withdraw to. This is a standard external account — nothing Global Account-specific. ```bash theme={null} curl -X POST "$GRID_BASE_URL/customers/external-accounts" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "currency": "USD", "platformAccountId": "jane_doe_checking", "accountInfo": { "accountType": "USD_ACCOUNT", "accountNumber": "1234567890", "routingNumber": "021000021", "bankAccountType": "CHECKING", "beneficiary": { "beneficiaryType": "INDIVIDUAL", "fullName": "Jane Doe", "birthDate": "1990-01-15", "nationality": "US", "address": { "line1": "123 Main Street", "city": "San Francisco", "state": "CA", "postalCode": "94105", "country": "US" } } } }' ``` **Response:** `201 Created` with the new `ExternalAccount:...` id. ### 6. Create a withdrawal quote Create a quote with the Global Account as the source. Grid returns a `payloadToSign` in the quote's payment instructions — this is what the client will sign to authorize the transfer. ```bash theme={null} curl -X POST "$GRID_BASE_URL/quotes" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 10000000, "description": "Withdrawal to checking" }' ``` `lockedCurrencyAmount` is in the smallest unit of the locked side's currency. Here the sending currency is USDB (6 decimals), so `10000000` is 10.00 USDB. **Response:** ```json theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000006", "status": "PENDING", "createdAt": "2026-04-19T12:05:00Z", "expiresAt": "2026-04-19T12:10:00Z", "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "sendingCurrency": { "code": "USDB", "name": "USDB", "decimals": 6 }, "receivingCurrency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 }, "totalSendingAmount": 10000000, "totalReceivingAmount": 975, "exchangeRate": 1.0, "feesIncluded": 250000, "platformFeesIncluded": 0, "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000005", "paymentInstructions": [ { "accountOrWalletInfo": { "accountType": "EMBEDDED_WALLET", "payloadToSign": "{\"type\":\"ACTIVITY_TYPE_SIGN_TRANSACTION_V2\",\"timestampMs\":\"1746736509954\",\"organizationId\":\"org_abc123\",\"parameters\":{\"signWith\":\"wallet_abc123def456\",\"unsignedTransaction\":\"ea69b4bf05f775209f26ff0a34a05569180f7936579d5c4af9377ae550194f72\",\"type\":\"TRANSACTION_TYPE_ETHEREUM\"},\"generateAppProofs\":true}" }, "instructionsNotes": "Stamp the payloadToSign byte-for-byte and pass the stamp as the Grid-Wallet-Signature header on execute" } ] } ``` ### 7. Authenticate and sign The customer has an outstanding quote with a `payloadToSign`. Now we need a session signing key to sign it with. With `EMAIL_OTP`, the client generates a TEK (Target Encryption Key) pair, HPKE-encrypts the OTP code, and uses the TEK private key both to complete login and to sign the quote payload. **1. Your backend requests a fresh OTP** Ask Grid to send a fresh OTP email for the default `EMAIL_OTP` credential. The response includes `otpEncryptionTargetBundle` for the secure OTP flow. ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/challenge" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (200):** ```json theme={null} { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000001", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "otpEncryptionTargetBundle": "{\"version\":\"v1.0.0\",\"data\":\"7b22...\",\"dataSignature\":\"3045...\",\"enclaveQuorumPublic\":\"04a1...\"}", "createdAt": "2026-04-19T12:00:01Z", "updatedAt": "2026-04-19T12:05:00Z" } ``` Return `otpEncryptionTargetBundle` to the client. **2. Client encrypts the OTP and verifies** The client generates a fresh [P-256 key pair (the TEK)](client-keys#1-generate-a-client-key-pair), [HPKE-encrypts](client-keys#encrypt-the-otp-code-email_otp-only) `{otp_code, public_key}` under `otpEncryptionTargetBundle`, and sends the encrypted bundle to your backend. `public_key` must be the compressed public key (`02` or `03` prefix). In sandbox, use OTP code `000000`. Your backend calls verify with the encrypted bundle: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "EMAIL_OTP", "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" }' ``` **Response (202):** ```json theme={null} { "type": "EMAIL_OTP", "payloadToSign": "{\"login\":{\"publicKey\":\"02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31\"},\"tokenId\":\"7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21\",\"type\":\"USAGE_TYPE_LOGIN\"}", "requestId": "Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21", "expiresAt": "2026-04-19T12:10:00Z" } ``` Return `payloadToSign` and `requestId` to the client. **3. Client stamps payloadToSign and completes login** The client [stamps](client-keys#2-sign-a-payloadtosign) the exact UTF-8 bytes of `payloadToSign`, unchanged, with the TEK private key and sends the stamp back to your backend. Your backend retries the same request with the stamp: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ -d '{ "type": "EMAIL_OTP", "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" }' ``` **Response (200):** ```json theme={null} { "id": "Session:019542f5-b3e7-1d02-0000-000000000003", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-19T12:05:01Z", "updatedAt": "2026-04-19T12:05:01Z", "expiresAt": "2026-04-19T12:20:01Z" } ``` The TEK public key is now the session API key. The TEK private key **is** the session signing key — the client already has it. **4. Client stamps the quote payload** The client [signs the quote's `payloadToSign`](client-keys#2-sign-a-payloadtosign) with the same TEK private key. Return the full Grid wallet signature to your backend. > [!WARNING] > > Stamp the `payloadToSign` bytes exactly as Grid returned them. Do not parse, re-serialize, trim, or normalize the JSON — the stamp must cover the same bytes Grid's verifier hashes. The session signing key is now valid for 15 minutes, so subsequent account actions within that window (for example, a second withdrawal) can reuse it without another `/challenge` + `/verify` round-trip. ### 8. Execute the quote Call `/execute` with the stamp in the `Grid-Wallet-Signature` header. ```bash theme={null} curl -X POST "$GRID_BASE_URL/quotes/Quote:019542f5-b3e7-1d02-0000-000000000006/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: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" ``` **Response:** ```json theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000006", "status": "PROCESSING", "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000005", "totalSendingAmount": 10000000, "totalReceivingAmount": 975, "feesIncluded": 250000 } ``` The transaction is on its way. You'll receive standard transaction webhooks (`OUTGOING_PAYMENT`) as it settles — see [Transaction lifecycle](/platform-overview/core-concepts/transaction-lifecycle). ## Where to next > [card] [Client keys & signing](./client-keys) > > Generate the P-256 key pair, hold the session signing key, and sign payloads on Web, iOS, and Android. > [card] [Authentication](./authentication) > > OAuth and Email OTP flows, passkey reauthentication, and the full WebAuthn parameter mapping. > [card] [Sessions](./managing-sessions) > > List, refresh, and revoke active sessions. > [card] [Exporting a wallet](./exporting-wallet) > > Let a customer take their wallet seed off Grid. # Payouts & B2B Source: https://agents-docs.lightspark.com/payouts-and-b2b/index ![Payouts & B2B hero](https://mintcdn.com/agents-docs/qL8kqlSYbvexR8cg/images/heroes/hero-payouts.jpg?fit=max\&auto=format\&n=qL8kqlSYbvexR8cg\&q=85\&s=5972eb17171ac32d54348cbc5fcbe8c3) With Grid, you can send and receive low cost real-time payments to bank accounts worldwide through a single, simple API. Grid automatically routes each payment across its network of Grid switches, handling FX, blockchain settlement, and instant banking off-ramps for you. ![](/images/icons/lightning.svg) Instant Conversion Single API, global reach. Grid interacts with the Money Grid to route your payments globally. ![](/images/icons/shield.svg) Simplified Infrastructure No crypto handling. Grid converts between fiat and crypto instantly to simplify your implementation and minimize FX costs. ![](/images/icons/globe.svg) Global Settlement Real-time settlement. Leverages local instant banking rails and global low latency crypto rails to settle payments in real-time. ## Payment Flow **1. Fund Internal Account or prepare for Just-in-Time Funding** You can either prefund an internal account with fiat or receive just-in-time payment instructions as part of the quote. **2. Create Quote** Create a quote to lock exchange rate to the receiving foreign account and parse payment instructions. **3. Execute Quote** Execute the quote or send funds as per payment instructions to initiate the transfer from the internal account to the external bank account. ## Grid Features Customers interact with Grid through two main interfaces. > [tab] API > > Programmatic access to create customers, quotes, fund the account, send payments and reconcile via webhooks. > [tab] Dashboard > > Your development and operations team can use the dashboard to monitor payments and webhooks, manage API keys and environments, and troubleshoot with logs. Implementing cross-border payments with Grid is simple. Here's a quick overview of the main steps. ### Onboarding customers > [!NOTE] > > Grid has two customer onboarding options - one for non regulated entities where Grid handles the KYC/KYB process and one for regulated entities where you handle the KYC/KYB process. > > When creating customers, you'll be able to also connect external accounts or internal accounts to a specific customer and also add accounts for counterparties on the receiving end. > To learn more about accounts read our [internal accounts guide](/payouts-and-b2b/depositing-funds/internal-accounts) or setting up [external accounts guide](/payouts-and-b2b/depositing-funds/external-accounts) ### Funding Payments Grid supports multiple transaction funding options including prefunded accounts and real-time funding. You can prefund an account using several payment rails such as ACH, SEPA Instant, wire transfers, Lightning, and more. With real-time funding, you'll receive payment instructions as part of the quote. Once payment is received by our services, we'll initiate the payment to the receiver. ### Sending Payments To send with Grid, onboard an account for a customer and the counter party, then execute and fund a quote. Grid resolves the receiver by external bank details, returns min/max and an exchange rate, and provides funding instructions. Once funded, Grid handles FX and delivery to the receiving account. ### Environments > [!NOTE] > > Grid supports two environments: Sandbox and Production. Sandbox mirrors production behavior and webhooks so you can test receiver resolution, quotes and funding instructions, settlement status changes, and full end‑to‑end flows without moving real funds. Production uses live credentials and base URLs for real payments once you’re ready to launch. *** ### Country Availability ![](/images/icons/coins.svg) Cryptocurrency Available Globally Bitcoin (BTC) and Stablecoin transactions supported worldwide with no geographic restrictions. ![](/images/icons/blocks.svg) Full Platform Access United States & Europe Onboard as a platform with complete access to APIs, hosted KYC/KYB, dashboard, and business integrations. Send payments to 64 countries on local banking rails, in addition to BTC and stablecoins. ![](/images/icons/globe.svg) Local Payment Rails 64 Countries Receive payments via local rails like SEPA, PIX, UPI, and more. | Country | ISO Code | Payment Rails | | - | - | -: | | 🇦🇹 Austria | AT | `SEPA` `SEPA Instant` `SWIFT` | | 🇧🇩 Bangladesh | BD | `Bank Transfer` `Mobile Money` (bKash, Nagad) `SWIFT` | | 🇧🇪 Belgium | BE | `SEPA` `SEPA Instant` `SWIFT` | | 🇧🇯 Benin | BJ | `Mobile Money` (MTN, Moov) `SWIFT` | | 🇧🇷 Brazil | BR | `PIX` `SWIFT` | | 🇧🇬 Bulgaria | BG | `SEPA` `SEPA Instant` `SWIFT` | | 🇨🇲 Cameroon | CM | `Mobile Money` (Orange, MTN) `SWIFT` | | 🇨🇳 China | CN | `AliPay` `WeChat Pay` `Bank Transfer` `SWIFT` | | 🇨🇴 Colombia | CO | `Bank Transfer` `Mobile Money` (Nequi, Daviplata) `SWIFT` | | 🇭🇷 Croatia | HR | `SEPA` `SEPA Instant` | | 🇨🇾 Cyprus | CY | `SEPA` `SEPA Instant` `SWIFT` | | 🇨🇿 Czech Republic | CZ | `SEPA` `SEPA Instant` `SWIFT` | | 🇩🇰 Denmark | DK | `SEPA` `SEPA Instant` `SWIFT` | | 🇪🇬 Egypt | EG | `Bank Transfer` `Mobile Money` (Orange Cash, Vodafone Cash, Etisalat Cash, Meeza) `SWIFT` | | 🇪🇪 Estonia | EE | `SEPA` `SEPA Instant` `SWIFT` | | 🇫🇮 Finland | FI | `SEPA` `SEPA Instant` `SWIFT` | | 🇫🇷 France | FR | `SEPA` `SEPA Instant` `SWIFT` | | 🇩🇪 Germany | DE | `SEPA` `SEPA Instant` `SWIFT` | | 🇬🇭 Ghana | GH | `Bank Transfer` `Mobile Money` (MTN MoMo, AirtelTigo) `SWIFT` | | 🇬🇷 Greece | GR | `SEPA` `SEPA Instant` `SWIFT` | | 🇭🇹 Haiti | HT | `Mobile Money` (MonCash) `SWIFT` | | 🇭🇺 Hungary | HU | `SEPA` `SEPA Instant` `SWIFT` | | 🇮🇸 Iceland | IS | `SEPA` `SEPA Instant` `SWIFT` | | 🇮🇳 India | IN | `UPI` `NEFT` `RTGS` `SWIFT` | | 🇮🇩 Indonesia | ID | `Bank Transfer` `SWIFT` | | 🇮🇪 Ireland | IE | `SEPA` `SEPA Instant` `SWIFT` | | 🇮🇱 Israel | IL | `Bank Transfer` `SWIFT` | | 🇮🇹 Italy | IT | `SEPA` `SEPA Instant` `SWIFT` | | 🇨🇮 Ivory Coast | CI | `Mobile Money` (Wave) `SWIFT` | | 🇰🇪 Kenya | KE | `Mobile Money` (M-PESA) `SWIFT` | | 🇱🇻 Latvia | LV | `SEPA` `SEPA Instant` `SWIFT` | | 🇱🇮 Liechtenstein | LI | `SEPA` `SEPA Instant` `SWIFT` | | 🇱🇹 Lithuania | LT | `SEPA` `SEPA Instant` `SWIFT` | | 🇱🇺 Luxembourg | LU | `SEPA` `SEPA Instant` `SWIFT` | | 🇲🇼 Malawi | MW | `Mobile Money` (Airtel, TNM) `SWIFT` | | 🇲🇾 Malaysia | MY | `Bank Transfer` `SWIFT` | | 🇲🇹 Malta | MT | `SEPA` `SEPA Instant` `SWIFT` | | 🇲🇽 Mexico | MX | `SPEI` `SWIFT` | | 🇳🇱 Netherlands | NL | `SEPA` `SEPA Instant` `SWIFT` | | 🇳🇬 Nigeria | NG | `Bank Transfer` `SWIFT` | | 🇳🇴 Norway | NO | `SEPA` `SEPA Instant` `SWIFT` | | 🇵🇰 Pakistan | PK | `Bank Transfer` `Mobile Money` (JazzCash, EasyPaisa) `SWIFT` | | 🇵🇭 Philippines | PH | `Bank Transfer` `SWIFT` | | 🇵🇱 Poland | PL | `SEPA` `SEPA Instant` `SWIFT` | | 🇵🇹 Portugal | PT | `SEPA` `SEPA Instant` `SWIFT` | | 🇷🇴 Romania | RO | `SEPA` `SEPA Instant` `SWIFT` | | 🇷🇼 Rwanda | RW | `Mobile Money` (MTN, Airtel) `SWIFT` | | 🇸🇳 Senegal | SN | `Mobile Money` (Orange Money, Wave) `SWIFT` | | 🇸🇬 Singapore | SG | `PayNow` `FAST` `Bank Transfer` `SWIFT` | | 🇸🇰 Slovakia | SK | `SEPA` `SEPA Instant` `SWIFT` | | 🇸🇮 Slovenia | SI | `SEPA` `SEPA Instant` | | 🇿🇦 South Africa | ZA | `Bank Transfer` `SWIFT` | | 🇪🇸 Spain | ES | `SEPA` `SEPA Instant` `SWIFT` | | 🇸🇪 Sweden | SE | `SEPA` `SEPA Instant` `SWIFT` | | 🇨🇭 Switzerland | CH | `SEPA` `SEPA Instant` `SWIFT` | | 🇹🇿 Tanzania | TZ | `Mobile Money` (Airtel, Vodacom) `SWIFT` | | 🇹🇭 Thailand | TH | `Bank Transfer` `SWIFT` | | 🇹🇷 Turkey | TR | `Bank Transfer` `SWIFT` | | 🇺🇬 Uganda | UG | `Mobile Money` (Airtel, MTN) `SWIFT` | | 🇦🇪 United Arab Emirates | AE | `Bank Transfer` `SWIFT` | | 🇬🇧 United Kingdom | GB | `Faster Payments` `SWIFT` | | 🇺🇸 United States | US | `ACH` `Same-day ACH` `Wire Transfer` `RTP` `FedNow` `SWIFT` | | 🇻🇳 Vietnam | VN | `Bank Transfer` `SWIFT` | | 🇿🇲 Zambia | ZM | `Mobile Money` (Airtel, MTN, Zamtel) `SWIFT` | > [!NOTE] > > `SWIFT` is the international-wire fallback, available in USD and EUR to nearly every > destination — including the countries above, and many not listed here that have no local Grid > rail. Prefer the local rail where one exists: it is faster and cheaper. `SWIFT` cannot reach > sanctioned or prohibited destinations; Croatia and Slovenia are the two countries in this table > it does not cover, and both are fully served by `SEPA`. Contact your Lightspark account manager > to confirm coverage for a specific corridor. > [!NOTE] > > `Mobile Money` pays a wallet held against the beneficiary's phone number rather than a bank > account. Create the external account with `phoneNumber` (and, in corridors served by more than > one wallet, the `provider` or `bankName` that selects it) instead of an account number. > Corridors that list both rails accept either — pick the one your beneficiary holds. > > The wallets in parentheses show which providers a corridor reaches. Use the > [Discoveries API](/api-reference/discoveries/list-available-receiving-institution-names) for the > exact accepted values and current coverage, since wallet availability changes over time. > [!NOTE] > > China has two rails serving different beneficiaries. `AliPay` and `WeChat Pay` are > consumer wallet payouts — use these to pay an individual. `Bank Transfer` is > business-to-business only, so it cannot be used to pay an individual. Regional Summary ![](/images/icons/globe.svg) Europe Primary: SEPA/SEPA Instant 32 countries ![](/images/icons/globe.svg) Middle East and Africa Primary: Mobile Money and Bank Transfer 17 countries ![](/images/icons/globe.svg) Asia-Pacific Various instant payment systems 10 countries ![](/images/icons/globe.svg) Americas PIX, SPEI, ACH, FedNow 5 countries ## Coming Soon Grid is rapidly expanding. These corridors are next on our roadmap — get in touch to be first in line when they go live. | Country | ISO Code | | - | - | | 🇨🇦 Canada | CA | | 🇨🇳 China | CN | | 🇭🇰 Hong Kong | HK | | 🇰🇷 South Korea | KR | > [!NOTE] > > China already has live coverage in the table above, via Thunes (`AliPay` `WeChat Pay` `Bank Transfer`). > The entry here is a separate, in-progress corridor through a different provider. It doesn't reduce or > replace the existing China coverage. # Launch checklist Source: https://agents-docs.lightspark.com/payouts-and-b2b/launch/checklist Everything Lightspark reviews before you move from sandbox to production Work through this checklist before you request production access. Lightspark reviews each item and confirms your platform is ready to move real funds. > [!WARNING] > > Lightspark Legal approval is a hard gate on launch. You can't go live or onboard any end user until Legal has reviewed and approved your consent flow. Start assembling this evidence while you build, not after. ## Commercial and compliance prerequisites Complete these before you build the consent and disclosure flows below. They gate everything else. | Item | What Lightspark confirms | | - | - | | Signed contract | Your agreement with Lightspark is executed, with legal and compliance sign-off on both sides. | | KYB complete | Your business has passed Know Your Business verification. | | UBO verification complete | Every ultimate beneficial owner of your business is identified and verified. | | Funds flows validated | Lightspark has reviewed and signed off on how money moves between your platform, your end users, and Grid for each product you're launching. | ## End user agreements Present each of these agreements in your onboarding flow and capture affirmative consent before you onboard the end user. | Agreement | Where to get it | | - | - | | Lightspark terms of service | [End User Terms](https://www.lightspark.com/legal/grid/enduserterms) | | Lightspark e-sign consent | [E-Sign Consent](https://www.lightspark.com/legal/grid/enduserterms#e-sign-consent) | | Lightspark privacy policy | [Privacy Policy](https://www.lightspark.com/privacy-policy) | | Partner bank accountholder agreement | Send your fee schedule to your Lightspark representative. Lightspark generates the agreement from it for you to display. | | Partner bank privacy policy | [Your Privacy Rights](https://www.lead.bank/legal/privacy) | > [!WARNING] > > Link the e-sign consent to its own section of the End User Terms, as in the table above. A link to the full terms doesn't make the consent prominent enough. Your end user has to see and affirm it directly. ### Sample consent screen Present the agreements as two separate affirmative actions: one consent to receive records electronically, one acceptance of the terms. Each bracketed item links to the live agreement. ```text Consent screen copy theme={null} Account provided by [Partner Bank]. ☐ I consent to receive all disclosures, agreements, and records electronically. Lightspark [E-Sign Consent] ☐ I agree to Lightspark's [Terms of Service] and [Partner Bank]'s [Accountholder Agreement], and I acknowledge Lightspark's [Privacy Policy] and [Partner Bank]'s [Privacy Policy]. ``` Use unchecked checkboxes or an equivalent affirmative action. Pre-checked boxes and implied consent are not acceptable. ### Record each acceptance If you're an unregulated platform moving funds on Lightspark's money transmitter licenses, send evidence of acceptance as `agreementConsents` when you create the customer, with one entry per agreement the customer accepted. Call `GET /customers/agreements` for each agreement's `type` and current `version`, and send that `version` as `termsVersion`. Accepting one agreement doesn't imply accepting another, so record each one. The older `endUserTermsConsent` field is deprecated. For the field reference and the API behavior when consent is missing, see [Disclosures](/payouts-and-b2b/onboarding/disclosures). ## Transaction disclosures and records Beyond onboarding, you owe end users disclosures and records over the life of the account. | Requirement | When it applies | Where to start | | - | - | - | | Reg E Part B pre-transaction disclosure | Before an end user confirms an international remittance. Shows the exchange rate, fees, and the amount the recipient receives. | Quote response fields, see [Assessing fees](/payouts-and-b2b/payment-flow/assessing-fees) | | Receipts | Every completed transaction. | [Receipts](/payouts-and-b2b/payment-flow/receipts) | | Periodic statements | On the cadence your product commits to, for end users holding a balance. | [Periodic statements](/payouts-and-b2b/payment-flow/statements) | | ACH mandate | Before you debit an end user's bank account by ACH. Captures their authorization for the debit. | [External accounts](/payouts-and-b2b/depositing-funds/external-accounts) | ## If you're launching Cards Card programs carry the same commercial prerequisites above, plus one more agreement. * **Cardholder agreement** at card sign-up. Present it and capture acceptance before you issue the card. For the card issuing flow, see the [Cards](/cards/index) documentation. ## Technical readiness Confirm your integration handles production traffic and failure modes. * **Webhooks** are registered, signature verification is in place, and your handler is idempotent. See [Webhooks](/payouts-and-b2b/platform-tools/webhooks). * **Idempotency keys** are sent on every request that moves money, so a retry can't double-send. * **Error handling** covers rejected quotes, expired quotes, and failed payments, with each surfaced to the end user. See [Error handling](/payouts-and-b2b/payment-flow/error-handling). * **Reconciliation** ties every Grid transaction back to a record in your ledger. See [Reconciliation](/payouts-and-b2b/payment-flow/reconciliation). * **Monitoring and alerting** cover webhook delivery failures and stuck transactions. * **Sandbox UAT** exercises your full flow end to end, including the failure paths. See [Sandbox testing](/payouts-and-b2b/platform-tools/sandbox-testing). ## What to submit to Lightspark Send these items to your Lightspark representative to request review. **1. Screenshots of your consent and disclosure screens** Submit screenshots of your implemented interface showing every agreement and disclosure in the tables above. Lightspark Legal reviews them against the sample consent screen above. Include: * **The consent screen**, showing each agreement linked and the unchecked boxes the end user has to check. * **Each disclosure** in the screen where the end user sees it. Where the content is hosted on a page of your own, include the link alongside the screenshot. **2. A recording of your sandbox flow** Record a screen capture of the complete end user experience in sandbox, from a new end user through a completed payment. Cover: * Onboarding, including the consent screen and every agreement link * Identity verification (KYC or KYB) * Adding a funding source or payout account * Creating and confirming a payment, including the pre-transaction disclosure * The completed payment and the receipt the end user receives A single unedited recording is preferred. Narration is optional. **3. Your policies and audit reports** Send PDF copies of: * Your privacy policy * Your terms of service * Your information security policy * Your most recent ISO, SOC 2, or PCI audit reports, if you have them * Your most recent penetration test report, if you have one If you contact end users by phone call or text message, also send your Telephone Consumer Protection Act (TCPA) disclosure. > [!TIP] > > After Lightspark approves your submission, Lightspark creates a production platform under your existing account and configures the fees defined in your order form. You then generate production API keys for that platform from the dashboard. # Configuring Customers Source: https://agents-docs.lightspark.com/payouts-and-b2b/onboarding/configuring-customers Configuring customers for Payouts This guide provides comprehensive information about customer configuration in the Grid API, including customer types, registration processes, management, and bank account information. ## Customer Types The Grid API supports both individual and business customers. While the API schema itself makes most Personally Identifiable Information (PII) optional at the initial customer creation, specific fields may become mandatory based on the currencies the customer will transact with. Your platform's configuration (retrieved via `GET /config`) includes a `supportedCurrencies` array. If a customer is intended to use a specific currency, any fields listed for that currency **must** be provided when creating or updating the customer. ## Customer Registration Process When creating or updating customers, the `customerType` field must be specified as either `INDIVIDUAL` or `BUSINESS`. Depending if you are a regulated or unregulated platform your KYC/KYB requirements will vary. ## What to Collect What you collect depends on the customer type: **KYC** for `INDIVIDUAL` customers, **KYB** for `BUSINESS` customers. Grid validates against these requirements when you call `POST /verifications` — anything missing comes back as a `RESOLVE_ERRORS` entry naming the exact field or document. > [!NOTE] > > Most of these fields are optional to the API schema at customer creation and only become mandatory at verification. Two things widen the list further: the `providerRequiredCustomerFields` for each currency in your platform's configuration (`GET /config`), and the KYC provider's per-jurisdiction rules. Treat the lists below as the baseline, not the ceiling. ### KYC — individual customers Personal information, submitted via `POST /customers` (or `PATCH /customers/{customerId}`): | Field | Notes | | - | - | | `fullName` | Must contain both a given name and a family name — a single-word name is rejected at submission | | `birthDate` | `YYYY-MM-DD` | | `nationality` | ISO 3166-1 alpha-2 | | `address` | `line1`, `postalCode`, and `country` are required; `line2`, `city`, and `state` as applicable | | `idType` + `identifier` | A tax ID: `SSN` (format `###-##-####`) or `ITIN`, or `NON_US_TAX_ID` with its non-US `countryOfIssuance`. US residents must provide an `SSN` or `ITIN` | | `email` / `phoneNumber` | Required in regions that verify a contact channel before verification begins (for example, the EU) | Documents, uploaded via `POST /documents` — identity documents also require `documentNumber` and `issuingAuthority`: * **Identity document** — a `PASSPORT` (single image), or a `DRIVERS_LICENSE` / `NATIONAL_ID` / `RESIDENCE_PERMIT` uploaded as separate `FRONT` and `BACK` images via the `side` field. * **Proof of address** — requested in some jurisdictions and for some currencies. Any of `UTILITY_BILL`, `ELECTRICITY_BILL`, `RENT_OR_LEASE_AGREEMENT`, `BANK_STATEMENT`, or `TAX_RETURN`, dated within the last 3 months. Some jurisdictions, flows, and volume tiers also require enhanced due diligence. These are optional top-level fields on the individual customer — `sourceOfFundsCategories`, `sourceOfWealthCategories`, `purposeOfAccount`, `expectedMonthlyTransactionCount`, `expectedMonthlyTransactionVolume`, `annualIncomeRange`, `netWorthRange`, and `pepStatus`. The provider decides which apply; you learn which ones through `MISSING_FIELD` errors on `POST /verifications` rather than up front. Each `*Categories` field that includes `OTHER` also requires its matching `*OtherDescription`. ### KYB — business customers When Grid runs KYB for a business customer, the following information and documents are collected before onboarding completes. Use this list to plan what to gather from the business — provide it via `POST /customers`, `POST /beneficial-owners`, and `POST /documents` (or through the hosted flow). #### Business identifying information * Entity full legal name * Doing Business As (DBA) name, if applicable * Physical address — the principal place of business, local office, or other physical location of the entity opening the account * Countries of operation * Identification number — U.S. taxpayer identification number, or, for a foreign business without one, alternative government-issued documentation certifying the existence of the business #### Ownership and control structure Collected for: * **One control person** — a single individual with significant responsibility to control, manage, or direct the legal entity, **and** * **All beneficial owners** — every individual who owns 25% or more of the legal entity, directly or indirectly. For every such individual, provide: * Full name * Date of birth * Address * Identification number, by residency: * **U.S. persons** — Social Security Number (SSN) or Individual Taxpayer Identification Number (ITIN) * **Non-U.S. persons** — one or more of: ITIN, passport number with country of issuance, alien identification card number, or another government-issued document evidencing nationality or residence and bearing a photograph or similar safeguard #### Required documents * Company formation and existence documents. For example: * Certificate of incorporation * Articles of association * Proof of ownership and control structure. For example: * Corporate organization and ownership chart * Shareholder agreements * Operating agreements * Register of members * Certification of controlling person and beneficial owners * Proof of address, dated within the last 3 months. For example: * Utility bill * Bank statement * Lease agreement * Official correspondence * Tax ID or equivalent identifying-number documents * For non-U.S. beneficial owners — passport plus one additional government-issued ID. For example: * National ID ## Regulated Platforms > [!TIP] > > **Regulated platforms** have lighter KYC requirements since they handle compliance verification internally. The KYC/KYB flow allows you to onboard customers through direct API calls. Regulated financial institutions can: * **Direct API Onboarding**: Create customers directly via API calls with minimal verification * **Internal KYC/KYB**: Handle identity verification through your own compliance systems * **Reduced Documentation**: Only provide essential customer information required by your payment counterparty or service provider. * **Faster Onboarding**: Streamlined process for known, verified customers ### Creating customers via direct API For regulated platforms, you can create customers directly through the API without requiring external KYC verification: To register a new customer in the system, use the `POST /customers` endpoint: ```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": "customer_12345", "customerType": "INDIVIDUAL", "fullName": "Jane Doe", "birthDate": "1992-03-25", "nationality": "US", "address": { "line1": "123 Pine Street", "city": "Seattle", "state": "WA", "postalCode": "98101", "country": "US" } }' ``` The examples below show a more comprehensive set of data. Not all fields are strictly required by the API for customer creation itself, but become necessary based on currency and UMA provider requirements if using UMA. > [tab] Individual customer > > ```json theme={null} > { > "platformCustomerId": "9f84e0c2a72c4fa", > "customerType": "INDIVIDUAL", > "fullName": "John Sender", > "birthDate": "1985-06-15", > "address": { > "line1": "Paseo de la Reforma 222", > "line2": "Piso 15", > "city": "Ciudad de México", > "state": "Ciudad de México", > "postalCode": "06600", > "country": "MX" > } > } > ``` > [tab] Business Customer > > ```json theme={null} > { > "platformCustomerId": "b87d2e4a9c13f5b", > "customerType": "BUSINESS", > "businessInfo": { > "legalName": "Acme Corporation", > "country": "US", > "registrationNumber": "789012345", > "taxId": "123-45-6789", > "incorporatedOn": "2018-03-14", > "businessType": "INFORMATION" > }, > "address": { > "line1": "456 Oak Avenue", > "line2": "Floor 12", > "city": "Seattle", > "state": "WA", > "postalCode": "98101", > "country": "US" > } > } > ``` ## Unregulated Platforms > [!NOTE] > > **Unregulated platforms** rely on Grid to run KYC for individuals and KYB for businesses. You can onboard customers either through the **hosted KYC/KYB link flow** below, or by **submitting customer data directly through the API**. Both paths cover KYC and KYB, produce the same status transitions — on `kycStatus` for individuals, `kybStatus` for businesses — and emit the same `CUSTOMER.KYC_*` / `CUSTOMER.KYB_*` webhooks. You can also mix them: supply what you already hold through the API, then let the hosted flow collect the rest. Either path works for unregulated platforms: * **Hosted flow**: Redirect customers to a Grid-hosted link (or embed the provider SDK). Covers individuals and businesses alike. Best when you want Grid to handle the entire collection UX. * **Direct API onboarding**: Collect customer information in your own UI and submit it via the API. For `INDIVIDUAL` customers (KYC), personal information goes through `POST /customers`. For `BUSINESS` customers (KYB), you also register beneficial owners via `POST /beneficial-owners`. Submit for review with `POST /verifications`. ### Hosted KYC and KYB Link Flow The hosted flow provides a secure, hosted interface where customers complete verification. It serves both customer types — there is no separate KYB link endpoint. `POST /customers/{customerId}/kyc-link` generates the link, and `customerType` selects which flow the provider runs: * **`INDIVIDUAL`** — identity verification (KYC), tracked on `kycStatus`. * **`BUSINESS`** — business verification (KYB), tracked on `kybStatus`. The applicant confirms the company details, uploads the formation, ownership, and proof-of-address documents, and declares the control person and every beneficial owner holding 25% or more. The flow is two steps: create the customer with the information you have, then generate the link. The status starts at `UNVERIFIED` and moves to `PENDING` once the customer completes the flow and review begins. #### 1. Create the customer > [tab] Individual (KYC) > > Supply at least `customerType` and any fields you already have. > > ```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 '{ > "customerType": "INDIVIDUAL", > "platformCustomerId": "9f84e0c2a72c4fa", > "region": "US", > "currencies": ["USD", "USDC"], > "email": "jane.doe@example.com", > "fullName": "Jane Doe" > }' > ``` > [tab] Business (KYB) > > `customerType: BUSINESS` requires these fields — the request fails without them: > > | Field | Notes | > | - | - | > | `businessInfo.legalName` | Full legal entity name | > | `businessInfo.country` | Country of incorporation, ISO 3166-1 alpha-2. Sets the applicant's jurisdiction and the tax-ID format validated against | > | `businessInfo.taxId` | Validated against `businessInfo.country` | > | `businessInfo.incorporatedOn` | `YYYY-MM-DD` | > > ```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 '{ > "customerType": "BUSINESS", > "platformCustomerId": "biz-acme-001", > "region": "US", > "currencies": ["USD", "USDC"], > "email": "finance@acme.com", > "businessInfo": { > "legalName": "Acme Corporation, Inc.", > "country": "US", > "taxId": "47-1234567", > "incorporatedOn": "2018-03-14", > "registrationNumber": "5523041", > "entityType": "LLC" > } > }' > ``` > > Everything else is optional to the schema, but three groups matter in practice: > > * **Contact channels** — `email` and/or `phoneNumber`, plus `businessInfo.primaryContactFirstName` and `primaryContactLastName`, are required in regions that verify a named representative before verification begins (for example the EU). > * **Currency-driven fields** — anything listed in `providerRequiredCustomerFields` for a currency the business will transact in. > * **Everything the review needs** — `address`, `registrationNumber`, `entityType`, `countriesOfOperation`, `businessType`, `purposeOfAccount`, `sourceOfFunds`, and the expected-activity fields aren't enforced at creation. Whatever you omit, the applicant is asked for in the flow. Persist the returned `id` (the Grid customer ID) — you'll need it for the next step. > [!TIP] > > Business information you supply is prefilled into the hosted flow, so send as much as you have — via `POST /customers` or a later `PATCH /customers/{customerId}` — **before** generating the link. Every field you pass is one the applicant doesn't retype, and one fewer chance for their answer to diverge from your record. #### 2. Generate the link ```bash theme={null} curl -X POST "https://api.lightspark.com/grid/2025-10-13/customers/Customer:019542f5-b3e7-1d02-0000-000000000001/kyc-link" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "redirectUri": "https://yourapp.com/onboarding-complete" }' ``` **Response:** ```json theme={null} { "kycUrl": "https://kyc.lightspark.com/onboard/abc123def456", "expiresAt": "2027-01-15T14:32:00Z", "provider": "SUMSUB", "token": "_act-sbx-jwt-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` > [!TIP] > > The response field is named `kycUrl` for both customer types; for a `BUSINESS` customer it opens the KYB flow. For providers that support direct SDK integration (currently SUMSUB), a `token` is also returned — pass it to the provider's web SDK to embed verification in your own UI instead of redirecting. Both update the customer's status identically. #### The flow end to end **1. Create the customer** Call `POST /customers` with `customerType` and any pre-fill fields you have. The returned `id` is the customer's Grid ID. Their status — `kycStatus` for an individual, `kybStatus` for a business — starts at `UNVERIFIED`, becomes `PENDING` while under review, and lands on `APPROVED` or `REJECTED` when the decision is made. (`HOLD` means the customer may be asked to supply more information.) **2. Verify contact channels (only where required)** Some regions (for example, the EU) require the customer's email and/or phone to be verified before verification can begin. For a business customer, these are the primary contact's details. When this applies, the customer carries a `contactVerification` object listing **only the channels that are required** — for example `{ "email": "PENDING", "phone": "PENDING" }`, or just `{ "email": "PENDING" }` if a provider requires email alone. When the object is absent, no contact verification is required and you can skip this step. For each channel that's present, drive a send-then-confirm pair (the provider delivers a one-time code by email/SMS): * `POST /customers/{customerId}/verify-email`, then `POST /customers/{customerId}/verify-email/confirm` with `{ "code": "..." }` * `POST /customers/{customerId}/verify-phone`, then `POST /customers/{customerId}/verify-phone/confirm` with `{ "code": "..." }` Re-POST the send endpoint to resend a code. Each channel moves `PENDING` → `VERIFIED`. (`verify-email`/`verify-phone` return `409` for a channel the provider doesn't require.) > [!NOTE] > > Generating the link before every present channel is `VERIFIED` returns `409`. **3. Generate the link** Call `POST /customers/{customerId}/kyc-link`. Each call returns a fresh single-use `kycUrl` and `expiresAt`; previously-issued links remain single-use but aren't invalidated. > [!NOTE] > > The `redirectUri` you pass is embedded in the generated `kycUrl` and is used to automatically return the customer to your application after they complete verification. **4. Send the customer through verification** Redirect the customer to `kycUrl`, or — if you want to embed the flow directly — initialize the provider's SDK with the returned `token`. A business applicant completes the company details, uploads the company documents, and declares the beneficial owners here. > [!WARNING] > > The hosted URL is single-use and expires at `expiresAt`. If a customer needs to retry, call the endpoint again to generate a new link. **5. Track the decision** Reaching your `redirectUri` only means the customer **finished the hosted flow** — not that they were approved. Wait for the final decision in one of two ways: * **Webhook (recommended):** Subscribe to `CUSTOMER.KYC_APPROVED` / `CUSTOMER.KYC_REJECTED` (and `CUSTOMER.KYB_APPROVED` / `CUSTOMER.KYB_REJECTED` for business customers) to be notified when the customer reaches a terminal status. `CUSTOMER.KYC_PENDING` (and the `KYB_PENDING` sibling) also fires when the customer is submitted for review — subscribe to it as well if you want to surface an "under review" state to the customer. * **Polling:** Call `GET /customers/{customerId}` and inspect `kycStatus` (individuals) or `kybStatus` (businesses). `GET /verifications?customerId=...` gives the finer-grained `verificationStatus` and `errors` array. That detail drives direct API onboarding; in the hosted flow the customer resolves it inside the flow, so integrate against `kycStatus` / `kybStatus`. **6. Handle completion** On `APPROVED`, the customer is ready to transact — proceed with account setup and unlock funding. On `REJECTED`, surface the appropriate next step (for example, regenerate the link or request manual review). While the status is `PENDING`, let the customer finish account setup but block funding and money movement. > [!WARNING] > > Testing the business flow in sandbox: by default the hosted flow collects the full packet, and a `businessInfo.registrationNumber` **not** ending in `001`, `002`, or `003` auto-approves the customer once that submission is complete. With **Skip verification paperwork** on, that suffix resolves before any packet validation, leaving the hosted flow nothing to verify — so it asks for no documents and no beneficial owners. Use a `003` suffix to exercise document and UBO collection. See [Sandbox testing](/api-reference/sandbox-testing). ### Direct API Onboarding Prefer to collect identity information in your own UI and submit it to Grid yourself? Use the API directly instead of redirecting to a hosted link. The customer's `kycStatus` transitions the same way and you receive the same `CUSTOMER.KYC_APPROVED` / `CUSTOMER.KYC_REJECTED` / `CUSTOMER.KYC_PENDING` (and `CUSTOMER.KYB_*` equivalents) webhooks. The shape of the flow depends on the customer type: * **KYC (`INDIVIDUAL` customers)** — supply the customer's personal information through the customer endpoint. No beneficial owners are involved. * **KYB (`BUSINESS` customers)** — create the business customer, then register its beneficial owners, directors, and officers individually. > [tab] KYC (individual) > > **1. Create the customer with personal information** > > Call `POST /customers` with `customerType: INDIVIDUAL` and the personal information collected from the customer (legal name, date of birth, address, nationality, etc.). The returned `id` is the customer's Grid ID; `kycStatus` starts at `UNVERIFIED` and moves to `PENDING` once you submit for verification. > > > [!NOTE] > > > > `fullName` must contain both a given name and a family name (for example, `"Jane Doe"`). The name is split into first/last for the verification provider, and a single-word name is rejected at submission. > > ```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 '{ > "customerType": "INDIVIDUAL", > "platformCustomerId": "9f84e0c2a72c4fa", > "fullName": "Jane Doe", > "birthDate": "1985-06-15", > "nationality": "US", > "email": "jane.doe@example.com", > "address": { > "line1": "123 Pine Street", > "city": "Seattle", > "state": "WA", > "postalCode": "98101", > "country": "US" > } > }' > ``` > > > [!NOTE] > > > > Where the customer's provider requires it (for example, EU customers), the customer carries a `contactVerification` object and their email and phone must be `VERIFIED` before you submit for verification. Drive each channel with the send-then-confirm pairs (`verify-email` / `verify-email/confirm` and `verify-phone` / `verify-phone/confirm`) described in the Hosted flow's "Verify email and phone" step. When `contactVerification` is absent, no contact verification is required. > > **2. Upload the identity document** > > Individual verification requires an identity document — a `PASSPORT` (single image), or a `DRIVERS_LICENSE` / `NATIONAL_ID` / `RESIDENCE_PERMIT` (uploaded as separate `FRONT` and `BACK` images via the `side` field). Some jurisdictions or currencies also request additional documents such as proof of address. Upload each one with `POST /documents` using `multipart/form-data`; identity documents also require `documentNumber` and `issuingAuthority`. Files must be `application/pdf`, `image/jpeg`, or `image/png`, and 10 MB or smaller. A photo of a document has to show all four corners in frame, in focus and in color, or the upload is rejected with `422 DOCUMENT_REJECTED`. > > ```bash theme={null} > curl -X POST "https://api.lightspark.com/grid/2025-10-13/documents" \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -F "documentHolder=Customer:019542f5-b3e7-1d02-0000-000000000001" \ > -F "documentType=PASSPORT" \ > -F "documentNumber=A12345678" \ > -F "issuingAuthority=U.S. Department of State" \ > -F "country=US" \ > -F "file=@./passport.jpg" > ``` > > **3. Submit for verification** > > Call `POST /verifications` to submit the customer for review. The response includes a `verificationStatus`. If anything is missing, `verificationStatus` is `RESOLVE_ERRORS` and the `errors` array describes exactly what to collect before retrying. > > ```bash theme={null} > curl -X POST "https://api.lightspark.com/grid/2025-10-13/verifications" \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -H "Content-Type: application/json" \ > -d '{ > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001" > }' > ``` > > **Submitted successfully:** > > ```json theme={null} > { > "id": "Verification:019542f5-b3e7-1d02-0000-000000000002", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "verificationStatus": "IN_PROGRESS", > "errors": [], > "createdAt": "2025-10-03T12:00:00Z" > } > ``` > > **Blocked by missing data:** > > ```json theme={null} > { > "id": "Verification:019542f5-b3e7-1d02-0000-000000000001", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "verificationStatus": "RESOLVE_ERRORS", > "errors": [ > { > "resourceId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "type": "MISSING_IDENTITY_DOCUMENT", > "acceptedDocumentTypes": ["PASSPORT", "DRIVERS_LICENSE", "NATIONAL_ID"], > "reason": "Identity document is required" > } > ], > "createdAt": "2025-10-03T12:00:00Z" > } > ``` > > **4. Track the decision** > > Track terminal `kycStatus` transitions via the `CUSTOMER.KYC_APPROVED` / `CUSTOMER.KYC_REJECTED` webhook (recommended) or by polling `GET /customers/{customerId}`. `CUSTOMER.KYC_PENDING` also fires when the customer is submitted for review — subscribe to it if you want to surface an "under review" state. On `APPROVED`, unlock funding and money movement. > [tab] KYB (business) > > When Grid runs KYB for a business customer, the following information and documents are collected before onboarding completes. Use this list to plan what to gather from the business — provide it via `POST /customers`, `POST /beneficial-owners`, and `POST /documents` (or through the hosted flow). > > #### Business identifying information > > * Entity full legal name > * Doing Business As (DBA) name, if applicable > * Physical address — the principal place of business, local office, or other physical location of the entity opening the account > * Countries of operation > * Identification number — U.S. taxpayer identification number, or, for a foreign business without one, alternative government-issued documentation certifying the existence of the business > > #### Ownership and control structure > > Collected for: > > * **One control person** — a single individual with significant responsibility to control, manage, or direct the legal entity, **and** > * **All beneficial owners** — every individual who owns 25% or more of the legal entity, directly or indirectly. > > For every such individual, provide: > > * Full name > * Date of birth > * Address > * Identification number, by residency: > * **U.S. persons** — Social Security Number (SSN) or Individual Taxpayer Identification Number (ITIN) > * **Non-U.S. persons** — one or more of: ITIN, passport number with country of issuance, alien identification card number, or another government-issued document evidencing nationality or residence and bearing a photograph or similar safeguard > > #### Required documents > > * Company formation and existence documents. For example: > * Certificate of incorporation > * Articles of association > * Proof of ownership and control structure. For example: > * Corporate organization and ownership chart > * Shareholder agreements > * Operating agreements > * Register of members > * Certification of controlling person and beneficial owners > * Proof of address, dated within the last 3 months. For example: > * Utility bill > * Bank statement > * Lease agreement > * Official correspondence > * Tax ID or equivalent identifying-number documents > * For non-U.S. beneficial owners — passport plus one additional government-issued ID. For example: > * National ID > > **1. Create the business customer** > > Call `POST /customers` with `customerType: BUSINESS` and the business information (legal name, country of incorporation, registration number, tax ID, registered address). > > ```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 '{ > "customerType": "BUSINESS", > "platformCustomerId": "b87d2e4a9c13f5b", > "businessInfo": { > "legalName": "Acme Corporation", > "country": "US", > "registrationNumber": "789012345", > "taxId": "123-45-6789", > "incorporatedOn": "2018-03-14", > "businessType": "INFORMATION" > }, > "address": { > "line1": "456 Oak Avenue", > "city": "Seattle", > "state": "WA", > "postalCode": "98101", > "country": "US" > } > }' > ``` > > **2. Register beneficial owners** > > Register each beneficial owner, director, or officer with `POST /beneficial-owners`. Each one is verified individually with their own personal information. > > ```bash theme={null} > curl -X POST "https://api.lightspark.com/grid/2025-10-13/beneficial-owners" \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -H "Content-Type: application/json" \ > -d '{ > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "roles": ["DIRECTOR"], > "ownershipPercentage": 40, > "personalInfo": { > "firstName": "Jane", > "lastName": "Doe", > "birthDate": "1985-06-15", > "nationality": "US", > "address": { > "line1": "123 Pine Street", > "city": "Seattle", > "state": "WA", > "postalCode": "98101", > "country": "US" > }, > "idType": "SSN", > "identifier": "123-45-6789" > } > }' > ``` > > **3. Upload supporting documents** > > Upload business documents (registration, articles of incorporation) and any documents required for individual beneficial owners with `POST /documents`. Reference the company by `customerId` and each owner by `beneficialOwnerId`. > > ```bash theme={null} > curl -X POST "https://api.lightspark.com/grid/2025-10-13/documents" \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -F "documentHolder=BeneficialOwner:019542f5-b3e7-1d02-0000-00000000abcd" \ > -F "documentType=PASSPORT" \ > -F "documentNumber=B98765432" \ > -F "issuingAuthority=U.S. Department of State" \ > -F "country=US" \ > -F "file=@./owner-passport.jpg" > ``` > > **4. Submit for verification** > > Call `POST /verifications` to submit the business for review. The response includes a `verificationStatus`. If beneficial owners, fields, or documents are missing, `verificationStatus` is `RESOLVE_ERRORS` and the `errors` array tells you exactly what to collect — note that `resourceId` may point at either the customer or a specific beneficial owner. > > ```bash theme={null} > curl -X POST "https://api.lightspark.com/grid/2025-10-13/verifications" \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -H "Content-Type: application/json" \ > -d '{ > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001" > }' > ``` > > **Blocked by missing data:** > > ```json theme={null} > { > "id": "Verification:019542f5-b3e7-1d02-0000-000000000001", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "verificationStatus": "RESOLVE_ERRORS", > "errors": [ > { > "resourceId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "type": "MISSING_FIELD", > "field": "customer.address.line1", > "reason": "Business address line 1 is required" > }, > { > "resourceId": "BeneficialOwner:019542f5-b3e7-1d02-0000-000000000002", > "type": "MISSING_FIELD", > "field": "personalInfo.birthDate", > "reason": "Date of birth is required for beneficial owners" > } > ], > "createdAt": "2025-10-03T12:00:00Z" > } > ``` > > **Submitted successfully:** > > ```json theme={null} > { > "id": "Verification:019542f5-b3e7-1d02-0000-000000000002", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "verificationStatus": "IN_PROGRESS", > "errors": [], > "createdAt": "2025-10-03T12:00:00Z" > } > ``` > > **5. Track the decision** > > Track terminal `kycStatus` transitions via the `CUSTOMER.KYB_APPROVED` / `CUSTOMER.KYB_REJECTED` webhook (recommended) or by polling `GET /customers/{customerId}`. `CUSTOMER.KYB_PENDING` also fires when the business is submitted for review — subscribe to it if you want to surface an "under review" state. On `APPROVED`, unlock funding and money movement. ## Handling KYC/KYB Webhooks After a customer completes the KYC/KYB verification process, you'll receive webhook notifications about their KYC status. These notifications are sent to your configured webhook endpoint. > [!NOTE] > > For regulated platforms, customers are created with `APPROVED` KYC status by default. **Webhook Payload (sent to your endpoint):** ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000020", "type": "CUSTOMER.KYC_APPROVED", "timestamp": "2025-07-21T17:32:28Z", "data": { "id": "Customer:019542f5-b3e7-1d02-0000-000000000001", "platformCustomerId": "9f84e0c2a72c4fa", "customerType": "INDIVIDUAL", "umaAddress": "$john.doe@uma.domain.com", "kycStatus": "APPROVED", "fullName": "John Michael Doe", "birthDate": "1990-01-15", "nationality": "US", "address": { "line1": "123 Main Street", "line2": "Apt 4B", "city": "Seattle", "state": "WA", "postalCode": "98101", "country": "US" }, "createdAt": "2025-07-21T17:32:28Z", "updatedAt": "2025-07-21T17:32:28Z", "isDeleted": false } } ``` **Webhook Headers:** * `Content-Type: application/json` * `X-Grid-Signature: {"v": "1", "s": "base64_signature..."}` **id** `string` *required* Unique identifier for this webhook delivery. Use this for idempotency to prevent processing duplicate webhooks. **type** `string` *required* Status-specific event type. KYC/KYB webhooks use `CUSTOMER.*` types: * `CUSTOMER.KYC_APPROVED`: Individual customer verification completed successfully * `CUSTOMER.KYC_REJECTED`: Individual customer verification was rejected * `CUSTOMER.KYC_PENDING`: Individual customer is awaiting review * `CUSTOMER.KYB_APPROVED` / `CUSTOMER.KYB_REJECTED` / `CUSTOMER.KYB_PENDING`: business equivalents (only fires for `customerType: BUSINESS`) **data** `object` *required* The full customer resource object, same as the corresponding `GET /customers/{id}` endpoint would return. Includes all customer fields such as `id`, `kycStatus`, `fullName`, `birthDate`, `nationality`, `address`, etc. > [!NOTE] > > `CUSTOMER.KYC_PENDING` (or `CUSTOMER.KYB_PENDING`) fires when a customer enters `kycStatus: PENDING` (for example, after they are submitted for review). Final outcomes use `CUSTOMER.KYC_APPROVED` / `CUSTOMER.KYC_REJECTED` (and the `CUSTOMER.KYB_*` siblings for business customers). > [accordion] Webhook Implementation Example > > ```javascript theme={null} > // Example webhook handler for KYC status updates. > // CUSTOMER.KYC_APPROVED and CUSTOMER.KYC_REJECTED are terminal decisions; > // CUSTOMER.KYC_PENDING is an intermediate signal that the customer has been > // submitted for review (useful for surfacing "we're still reviewing" UI). > app.post('/webhooks/kyc-status', async (req, res) => { > const { type, data } = req.body; > > switch (type) { > case 'CUSTOMER.KYC_APPROVED': > // Activate customer account > await activateCustomer(data.id); > await sendWelcomeEmail(data.id); > break; > > case 'CUSTOMER.KYC_REJECTED': > // Notify support and customer > await notifySupport(data.id, 'KYC_REJECTED'); > await sendRejectionEmail(data.id); > break; > > case 'CUSTOMER.KYC_PENDING': > // Intermediate: customer submitted for review, no decision yet > await markCustomerUnderReview(data.id); > break; > > case 'CUSTOMER.KYB_APPROVED': > // Business customer decision: activate > await activateCustomer(data.id); > await sendWelcomeEmail(data.id); > break; > > case 'CUSTOMER.KYB_REJECTED': > // Business customer decision: notify > await notifySupport(data.id, 'KYB_REJECTED'); > await sendRejectionEmail(data.id); > break; > > case 'CUSTOMER.KYB_PENDING': > // Intermediate: business customer submitted for review > await markCustomerUnderReview(data.id); > break; > > default: > // Log unexpected types > console.log(`Unexpected webhook type ${type} for customer ${data.id}`); > } > > res.status(200).send('OK'); > }); > ``` ### Handling Verification Issues The `CUSTOMER.KYC_*` and `CUSTOMER.KYB_*` events above tell you the decision. The `VERIFICATION.*` events tell you **why a verification is stuck and what to send to unstick it** — they carry the full `Verification` resource, including the `errors` array naming each unmet requirement. | Event | Meaning | What to do | | - | - | - | | `VERIFICATION.RESOLVE_ERRORS` | Blocked on missing, invalid, or rejected data | Resolve every entry in `errors`, then re-submit | | `VERIFICATION.PENDING_MANUAL_REVIEW` | A reviewer is assessing the case | Nothing — wait for the terminal event | | `VERIFICATION.IN_PROGRESS` | Submitted; provider checks are running | Nothing — wait | | `VERIFICATION.APPROVED` | Verification passed | Unlock funding and money movement | | `VERIFICATION.REJECTED` | Verification failed | Surface the outcome; see the non-recoverable errors below | > [!NOTE] > > These fire for both individuals and businesses — the customer type is determined by the customer referenced in `data.customerId`. They are most useful for direct API onboarding, where your integration is responsible for collecting what's missing. In the hosted flow the applicant resolves most issues inside the flow itself, so integrate against `kycStatus` / `kybStatus` there. **Example payload:** ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000031", "type": "VERIFICATION.RESOLVE_ERRORS", "timestamp": "2025-08-15T14:32:00Z", "data": { "id": "Verification:019542f5-b3e7-1d02-0000-000000000011", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "verificationStatus": "RESOLVE_ERRORS", "errors": [ { "resourceId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "type": "MISSING_PROOF_OF_ADDRESS_DOCUMENT", "acceptedDocumentTypes": ["UTILITY_BILL", "BANK_STATEMENT"], "reason": "Proof of address document is required" } ], "createdAt": "2025-08-15T14:00:00Z" } } ``` ### Reading an error **resourceId** `string` *required* The resource to fix — a `Customer`, a `BeneficialOwner`, or a `Document`. On a business verification this is what tells you *whose* data is incomplete, since one verification covers the company and every owner. **type** `string` *required* The error category. Drives what you do next — see the remediation table below. **field** `string` Dot-notation path to the offending field (for example, `customer.address.line1`). Present for `MISSING_FIELD` and `INVALID_FIELD`. **acceptedDocumentTypes** `array` The document types that would satisfy the requirement — upload any one of them. Present on the document-related error types. **reason** `string` *required* Human-readable description of the issue. Safe to log; write your own customer-facing copy rather than surfacing this text directly. ### Resolving each error type | Error types | How to resolve | | - | - | | `MISSING_FIELD`, `INVALID_FIELD` | `PATCH` the resource named by `resourceId` (`/customers/{id}` or `/beneficial-owners/{id}`), setting the path given in `field` | | `MISSING_LEGAL_PRESENCE_DOCUMENT`, `MISSING_CONTROL_STRUCTURE_DOCUMENT`, `MISSING_OWNERSHIP_STRUCTURE_DOCUMENT`, `MISSING_PROOF_OF_ADDRESS_DOCUMENT`, `MISSING_IDENTITY_DOCUMENT`, `MISSING_GOOD_STANDING_DOCUMENT` | `POST /documents` with any type listed in `acceptedDocumentTypes` | | `INVALID_DOCUMENT`, `EXPIRED_DOCUMENT`, `POOR_QUALITY_DOCUMENT`, `UNREADABLE_DOCUMENT`, `INCOMPLETE_DOCUMENT`, `WRONG_DOCUMENT_TYPE`, `DOCUMENT_VERIFICATION_FAILED` | Collect a replacement — current, legible, complete, and of an accepted type — and upload it with `POST /documents` | | `MISSING_BENEFICIAL_OWNER`, `MISSING_CONTROL_PERSON` | `POST /beneficial-owners` for the missing individual. Every owner at 25% or more, plus one control person, must be registered | | `SUSPECTED_FRAUD_DOCUMENT`, `APPLICANT_SANCTIONED`, `APPLICANT_FRAUD`, `APPLICANT_CRIMINAL_RECORD`, `APPLICANT_REJECTED` | Not resolvable by re-submitting. Route these to your compliance process | > [!WARNING] > > The `APPLICANT_*` types and `SUSPECTED_FRAUD_DOCUMENT` are compliance outcomes, not data problems. Re-submitting the same customer will not clear them, and retry loops on these are counterproductive — handle them as a terminal outcome for that customer. ### Re-submitting Once every recoverable error is resolved, call `POST /verifications` again with the same `customerId`. The response carries the updated `verificationStatus` — `RESOLVE_ERRORS` again if anything is still outstanding, so you can iterate without waiting for another webhook. To re-read the state at any point, call `GET /verifications?customerId={customerId}`. > [!NOTE] > > Once a customer reaches `APPROVED` or `REJECTED`, further `POST /verifications` calls return `400`. > [accordion] Webhook Implementation Example > > ```javascript theme={null} > // Handler for VERIFICATION.* events. RESOLVE_ERRORS is the one that needs work: > // every entry in data.errors names a resource, a category, and often the exact > // field or the document types that would satisfy it. > const NON_RECOVERABLE = new Set([ > 'SUSPECTED_FRAUD_DOCUMENT', > 'APPLICANT_SANCTIONED', > 'APPLICANT_FRAUD', > 'APPLICANT_CRIMINAL_RECORD', > 'APPLICANT_REJECTED', > ]); > > app.post('/webhooks/verification-status', async (req, res) => { > const { id, type, data } = req.body; > > // The webhook id is unique per delivery — use it to drop duplicates. > if (await alreadyProcessed(id)) { > return res.status(200).send('OK'); > } > > switch (type) { > case 'VERIFICATION.RESOLVE_ERRORS': { > const blocking = data.errors.filter((e) => NON_RECOVERABLE.has(e.type)); > if (blocking.length > 0) { > await escalateToCompliance(data.customerId, blocking); > break; > } > // Ask the customer for exactly what's outstanding, then re-submit > // with POST /verifications once it's all supplied. > await requestOutstandingItems(data.customerId, data.errors); > break; > } > > case 'VERIFICATION.PENDING_MANUAL_REVIEW': > case 'VERIFICATION.IN_PROGRESS': > await markCustomerUnderReview(data.customerId); > break; > > case 'VERIFICATION.APPROVED': > await activateCustomer(data.customerId); > break; > > case 'VERIFICATION.REJECTED': > await notifySupport(data.customerId, 'VERIFICATION_REJECTED'); > break; > > default: > console.log(`Unexpected webhook type ${type} for ${data.customerId}`); > } > > await recordProcessed(id); > res.status(200).send('OK'); > }); > ``` ## Customer Management ### Retrieving Customer Information You can retrieve customer information using either the Grid-assigned customer ID or your platform's customer ID: ```bash Grid-assigned customer ID theme={null} curl -X GET "https://api.lightspark.com/grid/2025-10-13/customers/{customerId}" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ```bash List customers with a filter theme={null} curl -X GET "https://api.lightspark.com/grid/2025-10-13/customers?platformCustomerId={platformCustomerId}&customerType={customerType}&createdAfter={createdAfter}&createdBefore={createdBefore}&cursor={cursor}&limit={limit}" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Note that this example shows all available filters. You can use any combination of them. ### Updating Customer Information To update customer information: ```bash theme={null} curl -X PATCH "https://api.lightspark.com/grid/2025-10-13/customers/{customerId}" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerType": "INDIVIDUAL", "address": { "line1": "456 Market St", "city": "Seattle", "state": "WA", "postalCode": "98101", "country": "US" } }' ``` Note that not all customer information can be updated. Particularly for non-regulated platforms, you cannot update personal information after the customer has been created. The `customerType` discriminator is required on PATCH so the API knows which set of optional fields to validate. To update bank-account details, use the `/customers/external-accounts` endpoints — bank accounts are no longer managed inline on the customer resource. ## Bank Account Information The API supports various bank account formats based on country and funding type. There are two types of funding mechanisms supported by Grid: an omnibus FBO (for benefit of) account owned by the platform, or direct customer-owned accounts. You must provide the correct format based on the customer's region and bank account type. ### Optional Platform Account ID All bank account types support an optional `platformAccountId` field that allows you to link bank accounts to your internal systems. This field can be any string that helps identify the account in your platform (e.g., database IDs, custom references, etc.). Example with platform account ID: ```json theme={null} { "accountType": "USD_ACCOUNT", "accountNumber": "123456789", "routingNumber": "987654321", "bankName": "Chase Bank", "platformAccountId": "chase_primary_1234" } ``` Common use cases for `platformAccountId`: * Tracking multiple bank accounts and uma addresses for the same customer * Linking accounts to internal accounting systems * Maintaining consistency between the Grid API and your platform's account records * Facilitating account reconciliation and reporting > [accordion] FBO Accounts > > FBO accounts are used when the platform has a single omnibus account that is used to fund all customers. Account details > must be provided manually at the platform level. For each customer, during you should simply provide: > > ```json theme={null} > "bankAccountInfo": { > "accountType": "FBO", > "currencyCode": "USD" // or any other currency code supported by the Grid API > } > ``` > > > [!TIP] > > > > Please contact us to set up FBO account for a specific currency. > [accordion] Mexico: CLABE > > ```json theme={null} > { > "accountType": "CLABE", > "clabeNumber": "123456789012345678", > "bankName": "Banco de México", > "platformAccountId": "banco_mx_primary_5678" > } > ``` > [accordion] United States: ACH (Account and Routing Number) > > ```json theme={null} > { > "accountType": "USD_ACCOUNT", > "accountNumber": "123456789", > "routingNumber": "987654321", > "bankAccountType": "CHECKING", > "bankName": "Chase Bank", > "platformAccountId": "chase_checking_1234" > } > ``` > [accordion] Brazil PIX > > ```json theme={null} > { > "accountType": "PIX", > "pixKey": "12345678901", > "pixKeyType": "CPF", > "platformAccountId": "pix_main_9012" > } > ``` > > PIX key types can be one of: `CPF`, `CNPJ`, `PHONE`, `EMAIL`, or `RANDOM`. > [accordion] India: UPI (Unified Payments Interface) > > ```json theme={null} > { > "accountType": "UPI", > "vpa": "somecustomer@okbank", > "platformAccountId": "upi_primary_1234" > } > ``` > [accordion] Europe: EUR (IBAN) > > ```json theme={null} > { > "accountType": "EUR_ACCOUNT", > "iban": "DE89370400440532013000", > "bankName": "Deutsche Bank", > "platformAccountId": "deutsche_primary_3456" > } > ``` ## Data Validation The Grid API performs validation on all customer data. Common validation rules include: * All required fields must be present based on customer type * Date of birth must be in YYYY-MM-DD format and represent a valid date * Names must not contain special characters or excessive spaces * Bank account information must follow country-specific formats * Addresses must include all required fields including country code If validation fails, the API will return a 400 Bad Request response with detailed error information. ## Best Practices 1. **Identity Verification**: Choose a proper KYC/KYB identity verification flow as detailed in the [Quickstart Guide](/payouts-and-b2b/quickstart#choosing-your-onboarding-flow) 2. **Data Security**: Store and transmit customer data securely, following data protection regulations 3. **Regular Updates**: Keep customer information up to date, especially banking details 4. **Error Handling**: Implement proper error handling to manage validation failures gracefully 5. **Idempotent Operations**: Use your platformCustomerId consistently to avoid duplicate customer creation ## Bulk Customer Import Operations For scenarios where you need to add many customers to the system at once, the API provides a CSV file upload endpoint. ### CSV File Upload For large-scale customer imports, you can upload a CSV file containing customer information: ```bash theme={null} curl -X POST "https://api.lightspark.com/grid/2025-10-13/customers/bulk/csv" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -F "file=@customers.csv" ``` The CSV file should follow a specific format with required and optional columns based on customer type. Here's an example: ```csv theme={null} umaAddress,platformCustomerId,customerType,fullName,birthDate,addressLine1,city,state,postalCode,country,businessLegalName $john.doe@uma.domain.com,customer123,INDIVIDUAL,John Doe,1990-01-15,123 Main St,San Francisco,CA,94105,US, $acme@uma.domain.com,biz456,BUSINESS,,,400 Commerce Way,Austin,TX,78701,US,Acme Corp ``` > [!TIP] > > CSV Upload Best Practices > > 1. Use a spreadsheet application to prepare your CSV file > 2. Validate data before upload (e.g., date formats, required fields) > 3. Include a header row with column names > 4. Use UTF-8 encoding for special characters > 5. Keep file size under 100MB for optimal processing You can track the job status through: 1. Webhook notifications (if configured) 2. Status endpoint: ```bash theme={null} curl -X GET "https://api.lightspark.com/grid/2025-10-13/customers/bulk/jobs/{jobId}" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Example job status response: ```json theme={null} { "id": "Job:019542f5-b3e7-1d02-0000-000000000006", "status": "PROCESSING", "progress": { "total": 5000, "processed": 2500, "successful": 2450, "failed": 50 }, "errors": [ { "correlationId": "biz456", "code": "INVALID_FIELD", "message": "Invalid bank account number" } ] } ``` > [!TIP] > > Best Practices for Bulk Operations > > 1. Use platform customer IDs to track individual customers in the bulk operation > 2. Implement proper error handling for partial successes > 3. Consider breaking very large datasets into multiple smaller jobs > 4. Use webhooks for real-time status updates on asynchronous jobs > 5. For CSV uploads, validate your data before submission ### CSV Format The CSV file should have the following columns: Required columns for all customers: * umaAddress: The customer's UMA address (e.g., `$john.doe@uma.domain.com`) * platformCustomerId: Your platform's unique identifier for the customer * customerType: Either "INDIVIDUAL" or "BUSINESS" Required columns for individual customers: * fullName: Individual's full name * birthDate: Date of birth in YYYY-MM-DD format * addressLine1: Street address line 1 * city: City * state: State/Province/Region * postalCode: Postal/ZIP code * country: Country code (ISO 3166-1 alpha-2) Required columns for business customers: * businessLegalName: Legal name of the business * addressLine1: Street address line 1 * city: City * state: State/Province/Region * postalCode: Postal/ZIP code * country: Country code (ISO 3166-1 alpha-2) Optional columns for all customers: * addressLine2: Street address line 2 * platformAccountId: Your platform's identifier for the bank account * description: Optional description for the customer Optional columns for individual customers: * email: Customer's email address Optional columns for business customers: * businessRegistrationNumber: Business registration number * businessTaxId: Tax identification number Bank-account details are no longer part of the bulk customer CSV — manage them separately via the `/customers/external-accounts` endpoints. # Disclosures Source: https://agents-docs.lightspark.com/payouts-and-b2b/onboarding/disclosures Expose Lightspark's End User Terms and provide evidence of your consent flow If you're an **unregulated platform** moving funds using Grid's money transmitter licenses, Grid (operating as Lightspark Payments, LLC) is the licensed money transmitter on your transactions. Your end users contract directly with Lightspark for these services, so you must present Lightspark's End User Terms to each end user and capture their consent before they transact. > [!WARNING] > > Exposing the End User Terms and collecting consent is a **launch precondition**. Provide Lightspark with evidence of your consent flow and receive approval before making Grid available to your end users. ## Include the End User Terms in your terms Copy Lightspark's End User Terms and append them to your own terms of service, so each end user accepts a single combined document. Including the URL to our End User Terms is not sufficient — you must embed the full text, available at: * **End User Terms:** [https://www.lightspark.com/legal/grid/enduserterms](https://www.lightspark.com/legal/grid/enduserterms) Present the combined terms in your onboarding or consent flow, and require each end user to affirmatively accept them (for example, an unchecked checkbox or an "I Agree" button) before they can use Grid. ## Record each acceptance Send the evidence of acceptance as `agreementConsents` when you create the customer — a list with one entry per agreement, each tagged with its `type`. Collect it for **both `INDIVIDUAL` and `BUSINESS` customers**. Call `GET /customers/agreements` to list every agreement Grid supports with its current `version` and hosted `url`, and send that `version` as `termsVersion` when you record an acceptance. > [!NOTE] > > If you already send `endUserTermsConsent`, it keeps working — it is deprecated but still > accepted, and is equivalent to one `agreementConsents` entry of type > `LIGHTSPARK_END_USER_TERMS`. Consent you have already recorded is migrated for you, so > there is nothing to re-collect for that agreement. Don't send both fields in one request. > [!WARNING] > > Until a customer's acceptance is on file, Grid does not provision their internal accounts, and customer-scoped transactions are rejected with `403 END_USER_TERMS_NOT_ACCEPTED`. ```bash theme={null} curl -sS -X POST "https://api.lightspark.com/grid/2025-10-13/customers" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerType": "INDIVIDUAL", "platformCustomerId": "9f84e0c2a72c4fa", "fullName": "Jane Doe", "agreementConsents": [ { "type": "LIGHTSPARK_END_USER_TERMS", "acceptedAt": "2025-10-03T12:00:00Z", "ipAddress": "198.51.100.24", "termsVersion": "2025-10-13", "acceptanceMethod": "CHECKBOX" } ] }' ``` | Field | Description | | - | - | | `type` | Which agreement was accepted. Accepting one agreement never implies acceptance of another. | | `acceptedAt` | When the customer accepted the terms (ISO 8601). | | `ipAddress` | IP address of the device the customer accepted from. | | `termsVersion` | Version identifier of the agreement the customer accepted, as returned for that `type` by `GET /customers/agreements`. Send the version they actually accepted — Grid rejects a version it doesn't recognize for that type with `400 END_USER_TERMS_VERSION_NOT_FOUND`. | | `acceptanceMethod` | How the customer accepted: `CHECKBOX` or `CLICK_TO_ACCEPT`. | The same field is accepted on `PATCH /customers/{customerId}`, so you can record consent for a customer that already exists — including customers you created before you started collecting it. Send only the agreements you are recording now; acceptances already on file for other types are left untouched. `customerType` is required on the update: ```bash theme={null} curl -sS -X PATCH "https://api.lightspark.com/grid/2025-10-13/customers/{customerId}" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerType": "INDIVIDUAL", "agreementConsents": [ { "type": "LIGHTSPARK_END_USER_TERMS", "acceptedAt": "2025-10-03T12:00:00Z", "ipAddress": "198.51.100.24", "termsVersion": "2025-10-13", "acceptanceMethod": "CLICK_TO_ACCEPT" } ] }' ``` Customer responses include an `agreementConsents` list holding the most recent acceptance recorded for each type, and an empty list until the first acceptance — read it back with `GET /customers/{customerId}` to confirm which customers still need consent. Regulated platforms rely on their own end user agreements and don't send this field. ## Provide evidence of your consent flow Send Lightspark evidence that your end users are shown the End User Terms and consent to them. Provide both: * **A screenshot of the consent screen** showing how the End User Terms are presented and accepted. * **How you track consent** — the record you keep each time an end user accepts, such as the end user identifier, the timestamp of acceptance, and the version of the terms accepted. # Implementation Overview Source: https://agents-docs.lightspark.com/payouts-and-b2b/onboarding/implementation-overview This page gives you a 10,000‑ft view of an end‑to‑end implementation. It is intentionally generalized because the flow supports multiple customer types and external account types (e.g., `USD_ACCOUNT`, `EUR_ACCOUNT`, `MXN_ACCOUNT`, `INR_ACCOUNT`). The detailed guides that follow provide concrete fields, edge cases, and step‑by‑step instructions. > [!NOTE] > > This overview highlights the main building blocks: platform setup, onboarding, funding, payout accounts, sending and receiving flows, reconciliation, sandbox testing, and go‑live enablement. ## Platform configuration Configure your platform once before building customer flows. * Provide webhook endpoints for outgoing and incoming payment notifications * Generate API credentials for Sandbox (and later Production) * Review regional capabilities (rails, currencies, settlement windows) ## Onboarding customers Onboard customers and accounts. There are two patterns: * Regulated entities can directly create customers by providing KYC/KYB data via API * Unregulated entities should request a KYC link and embed the hosted KYC flow; once completed, the customer can transact. You'll also need to persist the Grid customer IDs for use in payment flows ## Account funding Choose how transactions are funded based on your product design and region. * Prefunded: Maintain balances in one or more currencies/cryptocurrencies and spend from those balances * Just‑in‑time (JIT): Create a quote and fund it in real time using the payment instructions provided; ideal when you don’t wish to hold float > [!TIP] > > You can mix models as necessary. But it may make reconciliation more complex. ## External account creation Register accounts your customers will send to or receive from, such as CLABE (MX), IBAN (EU/UK), ACH/RTP(US), UPI (IN), Spark address, and others. * Capture beneficiary details (individual or business) and required banking fields * Validate account formats where applicable and map them to your internal customer ## Sending payments Sending consists of lookup, pricing, funding, and execution. * Resolve the counterparty: look up receiver account for compliance review and to determine capabilities * Create a quote: specify source/destination, currencies, and whether you lock sending or receiving amount; receive exchange rate, limits, fees, and (for JIT) funding instructions * Fund and execute: for prefunded, confirm/execute; for JIT, push funds exactly as instructed (amount, reference) and the platform handles FX and delivery * Observe status via webhooks and surface outcomes in your UI ## Receiving payments Enable customers to receive funds to their linked bank account. * Expose customer addressing to payers * The platform handles conversion and offramping to the receiver’s account currency * Approve or auto‑approve per your policy; update balances on completion via webhooks ## Reconciling transactions Implement operational processes to keep your ledger in sync. * Process webhooks idempotently; map statuses (pending, processing, completed, failed) * Tie transactions back to quotes and customers; persist references * Query for transactions by date range or other filters as necessary ## Testing in Sandbox Use Sandbox to build and validate end‑to‑end without moving real funds. * Exercise receiver lookup, quote creation, funding instructions, and webhook lifecycles * Validate compliance decisioning with realistic but synthetic data * Optionally use the Test Wallet as a counterparty for faster iteration (see Tools) ## Enabling Production When you’re ready to go live: * Complete corridor and provider onboarding as needed for your regions * Confirm webhook security, monitoring, and alerting are in place * Review rate limits, error handling, retries, and idempotency keys * Run final UAT in Sandbox, then request Production access from your Lightspark contact > [!TIP] > > Contact your Lightspark representative to enable Production and finalize corridor activations. # Platform Configuration Source: https://agents-docs.lightspark.com/payouts-and-b2b/onboarding/platform-configuration Configuring credentials, webhooks and currencies for your platform ## Supported currencies During onboarding, choose the currencies your platform will support. For prefunded models, Grid automatically creates per‑currency accounts for each new customer. You can add or remove supported currencies anytime in the Grid dashboard. ## API credentials and authentication Create API credentials in the Grid dashboard. Credentials are scoped to an environment (Sandbox or Production) and cannot be used across environments. * Authentication: Use HTTP Basic Auth with your API key and secret in the `Authorization` header. * Keys: Sandbox keys only work against Sandbox; Production keys only work against Production. > [!TIP] > > Never share or expose your API secret. Rotate credentials periodically and restrict access. ### Example: HTTP Basic Auth in cURL ```bash theme={null} # Using cURL's Basic Auth shorthand (-u): curl -sS -X GET "https://api.lightspark.com/grid/2025-10-13/config" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ## Base API path The base API path is consistent across environments; your credentials determine the environment. Base URL: `https://api.lightspark.com/grid/2025-10-13` (same for Sandbox and Production; your keys select the environment). ## Webhooks and signature verification Configure your webhook endpoint to receive payment lifecycle events. Webhooks use asymmetric (public/private key) signatures; verify each webhook using the Grid public key available in your dashboard. * Expose a public HTTPS endpoint (for development, reverse proxies like ngrok can help). You'll also need to set your webhook endpoint in the Grid dashboard. * When receiving webhooks, verify the `X-Grid-Signature` header against the exact request body using the dashboard-provided public key * Process events idempotently and respond with 2xx on success You can trigger a test delivery from the API to validate your endpoint setup. The public key for verification is shown in the dashboard; rotate and update it when instructed by Lightspark. ### Test your webhook endpoint Use the webhook test endpoint to send a synthetic event to your configured endpoint. ```bash theme={null} curl -sS -X POST "https://api.lightspark.com/grid/2025-10-13/webhooks/test" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Example test webhook payload: ```json theme={null} { "test": true, "timestamp": "2023-08-15T14:32:00Z", "webhookId": "Webhook:019542f5-b3e7-1d02-0000-000000000001", "type": "TEST" } ``` For more details about webhooks like retry policy and examples, take a look at our Webhooks documentation. # Assessing Fees Source: https://agents-docs.lightspark.com/payouts-and-b2b/payment-flow/assessing-fees Charge your own fee on top of Grid's fees and collect it into your platform account Grid lets you charge your own fee on the payments your customers send. You set the pricing, Grid collects the fee as part of the transaction, and the proceeds are credited to your platform internal account. Your fee is **additive**: it sits on top of the fees Grid charges you, and the two are never netted against each other. > [!NOTE] > > Configuring your own fee does not change Grid's **rates** — Grid's variable rate and fixed > fee stay exactly as contracted. It can still change the **absolute amount** Grid collects, > because Grid's variable fee is proportional to the amount being sent. When you lock the > receiving amount, adding your fee raises the sending amount needed to deliver it, so the > absolute amount Grid takes rises with it. ## What you can charge A platform fee has two components, and you can use either or both: | Component | Field | Charged as | | - | - | - | | Variable | `variableFeeBps` | Basis points (1 bps = 0.01%) of the sending amount, after fixed fees are deducted | | Fixed | `fixedFee` | A flat amount per transaction | Both are denominated in the **sending currency** and are charged on the sending side of the payment — your customer bears the fee as part of the transaction rather than you being billed for it separately. Which side absorbs it depends on `lockedCurrencySide`: lock the **sending** amount and your fee comes out of what the recipient receives; lock the **receiving** amount and the recipient still gets that amount, so the sender pays more. Fees are configured per **fee type**, which identifies the activity being charged: * `CROSS_CURRENCY_TRANSACTION` — a fee charged on transactions where the sending currency differs from the receiving currency * `RAIL` — a fee charged on any transaction that uses this payment rail Both fee types may apply to the same transaction, e.g. a cross-currency payment that goes out over ACH. Your platform will receive the sum of all applied fees. > [!NOTE] > > **Current limits.** Cross-currency fees are supported for a **USD** sending currency > only, and the fixed fee must be in that same currency — other currencies return > `NOT_IMPLEMENTED`. > > Rail fees cover `ACH`, `ACH_SAME_DAY`, `RTP`, `FEDNOW` and `WIRE`, going `OUT`, with the fixed fee in USD. > `ACH` and `ACH_SAME_DAY` are separate rails, so price each one with its own config. > Anything else returns `NOT_IMPLEMENTED`. > > Both are expanding — support for stablecoin sending currencies such as USDC is planned. > Watch the [changelog](/changelog) rather than assuming today's limits are permanent. > [!NOTE] > > A fee can only be charged on a transaction if you hold a platform internal account in that > transaction's **sending currency** — that account is where the fee is credited. As more > sending currencies become chargeable, add the matching platform account before you enable a > fee for them. ### How the amount is calculated Every fixed fee on the transaction — yours and Grid's — is subtracted first. The variable rates are then applied together to what remains: ```text theme={null} totalVariableFee = (sendingAmount - platformFixedFee - gridFixedFee) x (platformVariableFee + gridVariableFee) ``` Your share of that total is your own rate's portion of it. Each component is rounded independently to the smallest currency unit, so your fee and Grid's are each exact. For a \$10.00 USD payment where your fee is 30 bps + \$0.50 and Grid charges 100 bps with no fixed fee: ```text theme={null} after fixed fees = 1000 - 50 - 0 = 950 cents your variable = 950 x 0.0030 = 2.85 -> 3 cents your fixed = 50 cents your platform fee = 53 cents ``` > [!NOTE] > > The variable component is charged on the sending amount **net of all fixed fees**, not on > the gross sending amount — so Grid's fixed fee reduces the base your variable rate applies > to, and yours reduces Grid's. ## Configuring your fees ### Standing fees Standing fees apply to every matching transaction until you change them. Set them with `PATCH /config`: ```bash Set a standing platform fee 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 '{ "feeConfigs": [ { "feeType": "CROSS_CURRENCY_TRANSACTION", "sourceCurrency": "USD", "variableFeeBps": 30, "fixedFee": { "amount": 50, "currency": "USD" } } ] }' ``` `feeConfigs` is a **merge-by-key upsert**. Cross-currency fees are keyed by `(feeType, sourceCurrency)`, rail fees by `(feeType, rail, direction, originator)`: * Only the keys you send are touched. Configs you omit are left unchanged. * Omitting `feeConfigs` entirely leaves all your fee configuration unchanged. * There is at most one active config per key. A rail fee looks like this: ```bash Set a standing rail fee 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 '{ "feeConfigs": [ { "feeType": "RAIL", "rail": "ACH", "direction": "OUT", "originator": "GRID", "variableFeeBps": 0, "fixedFee": { "amount": 25, "currency": "USD" } } ] }' ``` To read your current fees back, call `GET /config` — the response includes a `feeConfigs` array with every currently-active config. ```bash Read current fee configuration theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/config' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ### Turning a fee off Set both `variableFeeBps` and `fixedFee.amount` to `0` for that key. The config is deactivated and stops applying to new transactions: ```json theme={null} { "feeConfigs": [ { "feeType": "CROSS_CURRENCY_TRANSACTION", "sourceCurrency": "USD", "variableFeeBps": 0, "fixedFee": { "amount": 0, "currency": "USD" } } ] } ``` ### Per-transaction overrides For promotions, negotiated rates, or VIP pricing, you can override your fee on a single transaction by passing `platformFeeOverride` when you create the quote. The override **replaces** every platform fee that would otherwise have applied to that transaction — it is not added to your standing fee. ```bash Quote with a discounted platform fee 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:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 10000, "platformFeeOverride": { "platformFixedFee": { "amount": 0, "currency": "USD" }, "platformVariableFeeBps": 10 } }' ``` Both `platformFixedFee` and `platformVariableFeeBps` are **required** inside the override object — pass `0` for a component you want to waive. To waive your fee entirely on a transaction, send `0` for both. > [!WARNING] > > An override applies only to the quote it is sent on. It never changes your standing > configuration, and it does not carry over to the next quote. Overrides are rejected with a `400 INVALID_INPUT` when: * The quote's sending currency is not USD. * `platformFixedFee.currency` does not match the quote's sending currency. * Either value is negative, or `platformVariableFeeBps` is above `10000` (100%). ## When fees are assessed A platform fee moves through three stages: **1. Priced at quote time** When you create a quote, Grid resolves your fee (standing config or override) and prices it into the quote. The amount is returned as `platformFeesIncluded` and is locked for the lifetime of the quote. **2. Charged when the payment executes** Executing the quote charges the fee as part of the sending amount. Your customer pays it — you are not invoiced for it separately. **3. Credited during settlement** The fee is credited to your platform internal account in the sending currency as one step of the payment's settlement, not in a single moment at the end. It can land before the transaction reaches `COMPLETED`. > [!WARNING] > > Because the credit happens partway through settlement, a payment that is **refunded after > the fee has landed** reverses that credit — the fee is pulled back out of your platform > account and returned to your customer. You will see the corresponding balance updates in > both directions. > > Treat a fee as earned only once its transaction is `COMPLETED`. A fee visible in your > balance while the payment is still in flight is not final. Every quote returns `platformFeesIncluded`, the portion of `feesIncluded` that you collect. It is `0` when no fee applies. Continuing the worked example above — a \$10.00 send with your 30 bps + \$0.50 fee and Grid's 100 bps: ```json Quote response theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000006", "status": "PENDING", "totalSendingAmount": 1000, "totalReceivingAmount": 863, "exchangeRate": 0.92, "feesIncluded": 63, "platformFeesIncluded": 53, "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000005" } ``` > [!NOTE] > > `platformFeesIncluded` is **already counted inside** `feesIncluded` — do not add the two > together. Subtract it from `feesIncluded` to get Grid's portion. > [!WARNING] > > Without a platform internal account in the sending currency, a payment carrying a fee fails > rather than silently dropping the fee. See > [Internal accounts](/payouts-and-b2b/depositing-funds/internal-accounts) to check that yours > exists. > > Platform fees are also not yet supported on every payment flow. When a quote's fee cannot > be delivered by the flow that would settle it, the quote is rejected at creation rather > than at settlement — so you find out before your customer is charged. ## Webhooks Platform fees do not introduce new webhook types. Two existing events carry the information you need: | Webhook | What it tells you | | - | - | | `OUTGOING_PAYMENT.*` | The transaction payload includes `platformFees` — the fee you collected on that payment. Available on every status in the lifecycle, including `COMPLETED` and `FAILED`. | | `INTERNAL_ACCOUNT.BALANCE_UPDATED` | Fired when your platform internal account balance changes — both when a fee is credited to it and when a refund reverses that credit back out. | To reconcile fee revenue as it accrues, listen for `OUTGOING_PAYMENT.COMPLETED` and read `platformFees` from the payload: ```json OUTGOING_PAYMENT.COMPLETED theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000007", "type": "OUTGOING_PAYMENT.COMPLETED", "timestamp": "2025-08-15T14:32:00Z", "data": { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000005", "status": "COMPLETED", "type": "OUTGOING", "fees": 63, "platformFees": 53 } } ``` > [!NOTE] > > A fee is only earned once the payment completes. A transaction that fails or is refunded > generates no revenue — and if the fee had already been credited, the refund reverses it — > so reconcile against each transaction's current `status` rather than counting `platformFees` > on every event you receive. See [Webhooks](/payouts-and-b2b/platform-tools/webhooks) for signature verification and delivery behavior. ## Querying transactions and revenue ### Per-transaction fees Every outgoing transaction carries `platformFees`, the portion of `fees` that you collected. Fetch a single transaction: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/transactions/Transaction:019542f5-b3e7-1d02-0000-000000000005' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Or list transactions over a date range and sum `platformFees` across the completed ones: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/transactions?status=COMPLETED&startDate=2025-10-01T00:00:00Z&endDate=2025-10-31T23:59:59Z' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Results are paginated — follow `nextCursor` until `hasMore` is `false` so your totals cover the whole period. See [List transactions](/payouts-and-b2b/payment-flow/list-transactions) for the full set of filters. ### Accrued balance Collected fees accumulate in your platform internal account. Query its balance for the running total credited to you and not yet withdrawn — bearing in mind it includes fees from payments still in flight, which a refund can still reverse: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/platform/internal-accounts?currency=USD' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [!NOTE] > > Your platform account is a single pool, like a bank balance — fee revenue is not held in a > separate sub-balance. If you deposit or spend from the same account, derive fee revenue by > summing `platformFees` across transactions rather than reading the balance. ## Testing in sandbox Platform fees work end to end in sandbox, so you can verify your pricing before going live. Money movements are simulated, but fees are calculated and credited exactly as they are in production. **1. Confirm you have a platform account** Call `GET /platform/internal-accounts?currency=USD`. If no account exists, fees have nowhere to land and payments carrying them will fail. **2. Configure a fee** `PATCH /config` with a `feeConfigs` entry, then `GET /config` to confirm it is active. **3. Fund a customer account** Use `POST /sandbox/internal-accounts/{accountId}/fund` to add a test balance. See [Sandbox testing](/payouts-and-b2b/platform-tools/sandbox-testing). **4. Create a quote and check the math** `POST /quotes` and confirm `platformFeesIncluded` matches what you expect for the amount you sent. This is the fastest way to validate a fee change — no execution needed. **5. Execute and verify the credit** Execute the quote, wait for the transaction to reach `COMPLETED`, then re-read your platform account balance. It should have increased by exactly `platformFeesIncluded`. > [!TIP] > > Quote-only testing is cheap and repeatable: because the fee is priced at quote creation, > you can sweep a range of amounts and compare `platformFeesIncluded` against your own > pricing model without moving any money. To test an override, add `platformFeeOverride` to step 4 and confirm `platformFeesIncluded` reflects the override rather than your standing config. ## Next steps ![](/images/icons/settings-gear2.svg) Platform configuration Configure currencies, webhooks, and credentials for your platform [Open](/payouts-and-b2b/onboarding/platform-configuration) ![](/images/icons/file-text.svg) List transactions Filter and paginate payment history to total your fee revenue [Open](/payouts-and-b2b/payment-flow/list-transactions) ![](/images/icons/hammer.svg) Sandbox testing Validate your fee configuration before going live [Open](/payouts-and-b2b/platform-tools/sandbox-testing) # Error Handling Source: https://agents-docs.lightspark.com/payouts-and-b2b/payment-flow/error-handling Handle payment failures, API errors, and transaction issues gracefully Learn how to handle errors when working with payments and transactions in Grid. Proper error handling ensures a smooth user experience and helps you quickly identify and resolve issues. ## HTTP status codes Grid uses standard HTTP status codes to indicate the success or failure of requests: | Status Code | Meaning | When It Occurs | | - | - | - | | `200 OK` | Success | Request completed successfully | | `201 Created` | Resource created | New customer, account, or credential created | | `202 Accepted` | Accepted for processing | Async operation initiated (e.g., bulk CSV upload) | | `400 Bad Request` | Invalid input | Missing required fields or invalid parameters | | `401 Unauthorized` | Authentication failed | Invalid or missing API credentials | | `403 Forbidden` | Permission denied | Insufficient permissions or customer not ready | | `404 Not Found` | Resource not found | Customer, transaction, or quote doesn't exist | | `408 Request Timeout` | Request timed out | The request body could not be read in time; retry | | `409 Conflict` | Resource conflict | Quote already executed, external account already exists, bank account still being validated | | `412 Precondition Failed` | UMA version mismatch | Counterparty doesn't support required UMA version | | `429 Too Many Requests` | Rate/volume limited | Rate limit hit or daily volume limit exceeded | | `422 Unprocessable Entity` | Missing info | Additional counterparty information required | | `424 Failed Dependency` | Counterparty issue | Problem with external UMA provider | | `500 Internal Server Error` | Server error | Unexpected server issue ([contact our team](https://www.lightspark.com/contact)) | | `501 Not Implemented` | Not implemented | Feature not yet supported | | `502 Bad Gateway` | Counterparty error | Counterparty returned an error or an invalid response; retry | | `504 Gateway Timeout` | Counterparty timeout | Counterparty could not be reached or timed out; retry | ## API error responses All error responses include a structured format: ```json theme={null} { "code": "INVALID_AMOUNT", "reason": "Amount must be greater than 0", "details": { "field": "amount", "value": -100 } } ``` ### Common error codes > [accordion] INVALID\_INPUT > > **Cause:** Missing required fields or invalid data format > > **Solution:** Check request parameters match API specification > > ```javascript theme={null} > // Error > { > "code": "INVALID_INPUT", > "reason": "Invalid account ID format" > } > > // Fix: Ensure proper ID format > const accountId = "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965"; > ``` > [accordion] QUOTE\_EXPIRED > > **Cause:** Attempting to execute an expired quote > > **Solution:** Create a new quote before executing > > ```javascript theme={null} > async function executeQuoteWithRetry(quoteId) { > try { > return await executeQuote(quoteId); > } catch (error) { > if (error.code === "QUOTE_EXPIRED") { > // Create new quote and execute > const newQuote = await createQuote(originalQuoteParams); > return await executeQuote(newQuote.id); > } > throw error; > } > } > ``` > [accordion] INSUFFICIENT\_BALANCE > > **Cause:** Internal account doesn't have enough funds > > **Solution:** Check balance before initiating transfer > > ```javascript theme={null} > async function safeSendPayment(accountId, amount) { > const account = await getInternalAccount(accountId); > > if (account.balance.amount < amount) { > throw new Error( > `Insufficient balance. Available: ${account.balance.amount}, Required: ${amount}` > ); > } > > return await createQuote({ accountId, amount }); > } > ``` > [accordion] BANK\_ACCOUNT\_VALIDATION\_PENDING > > **Cause:** Grid is still validating the US bank account on this request > > **Solution:** Wait and retry. Grid validates a newly added ACH account by sending a micro-entry to the bank and waiting out the return window, which takes a few banking days. The account has not been rejected, so do not ask the customer to re-enter their details. > > ```javascript theme={null} > async function createQuoteWhenAccountIsReady({ accountId, amount }) { > try { > return await createQuote({ accountId, amount }); > } catch (error) { > if (error.code === "BANK_ACCOUNT_VALIDATION_PENDING") { > // Surface the wait to the customer; the account is fine. > return { status: "AWAITING_ACCOUNT_VALIDATION" }; > } > throw error; > } > } > ``` > [accordion] INVALID\_BANK\_ACCOUNT > > **Cause:** Bank account details are invalid or incomplete > > **Solution:** Validate account details before submission > > ```javascript theme={null} > function validateUSAccount(account) { > if (!account.accountNumber || !account.routingNumber) { > throw new Error("Account and routing numbers required"); > } > > if (account.routingNumber.length !== 9) { > throw new Error("Routing number must be 9 digits"); > } > > return true; > } > ``` > [accordion] TRANSACTION\_SIZE\_LIMIT\_EXCEEDED > > **Cause:** The requested amount exceeds the maximum single-transaction limit for the trade corridor or withdrawal currency > > **Solution:** Reduce the transaction amount or split into multiple smaller transactions > > ```javascript theme={null} > async function handleLargeTransfer(amount, maxPerTransaction) { > if (amount <= maxPerTransaction) { > return await createQuote({ amount }); > } > > // Split into multiple transactions > const batches = []; > let remaining = amount; > > while (remaining > 0) { > const batchAmount = Math.min(remaining, maxPerTransaction); > batches.push(batchAmount); > remaining -= batchAmount; > } > > return Promise.all(batches.map((amt) => createQuote({ amount: amt }))); > } > ``` > [accordion] DAILY\_VOLUME\_LIMIT\_EXCEEDED > > **Cause:** The requested amount would exceed the daily volume limit for the trade corridor or withdrawal currency (HTTP 429) > > **Solution:** Wait until the next day or reduce the transaction amount > > ```javascript theme={null} > async function createQuoteWithLimitCheck(params) { > try { > return await createQuote(params); > } catch (error) { > if (error.code === "DAILY_VOLUME_LIMIT_EXCEEDED") { > // Notify user about daily limit > return { > error: "DAILY_VOLUME_LIMIT_EXCEEDED", > message: "Daily transfer limit reached. Try again tomorrow.", > retryAfter: getNextDayMidnight(), > }; > } > throw error; > } > } > > function getNextDayMidnight() { > const tomorrow = new Date(); > tomorrow.setDate(tomorrow.getDate() + 1); > tomorrow.setHours(0, 0, 0, 0); > return tomorrow.toISOString(); > } > ``` ## Transaction failure reasons When a transaction fails, the `failureReason` field provides specific details: ### Outgoing payment failures ```json theme={null} { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000030", "status": "FAILED", "type": "OUTGOING", "direction": "DEBIT", "failureReason": "QUOTE_EXECUTION_FAILED" } ``` **Common outgoing failure reasons:** * `QUOTE_EXPIRED` - Quote expired before execution * `QUOTE_RATE_UNAVAILABLE` - Quoted exchange rate was refused at execution; nothing was exchanged * `QUOTE_EXECUTION_FAILED` - Error executing the quote; a debited amount is refunded automatically * `FUNDING_AMOUNT_MISMATCH` - Funding amount doesn't match expected amount * `PAYOUT_RETURNED` - Receiving bank returned or reversed the payout * `LIMIT_EXCEEDED` - Payout exceeds a partner limit * `ACCOUNT_CANNOT_RECEIVE` - Recipient account can't accept the payment * `ACCOUNT_INVALID` - Recipient account details are wrong or not found * `COMPLIANCE_REJECTED` - Payout partner rejected on compliance grounds ### Incoming payment failures ```json theme={null} { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000005", "status": "FAILED", "type": "INCOMING", "direction": "CREDIT", "failureReason": "PAYMENT_APPROVAL_TIMED_OUT" } ``` **Common incoming failure reasons:** * `PAYMENT_APPROVAL_TIMED_OUT` - Webhook approval not received within 5 seconds * `PAYMENT_APPROVAL_WEBHOOK_ERROR` - Webhook returned an error * `OFFRAMP_FAILED` - Failed to convert and send funds to destination * `QUOTE_EXPIRED` - Quote expired during processing ## Handling failures ### Monitor transaction status ```javascript theme={null} async function monitorTransaction(transactionId) { const maxAttempts = 30; // 5 minutes with 10-second intervals let attempts = 0; while (attempts < maxAttempts) { const transaction = await getTransaction(transactionId); if (transaction.status === "COMPLETED") { return { success: true, transaction }; } if (transaction.status === "FAILED") { return { success: false, transaction, failureReason: transaction.failureReason, }; } // Still processing await new Promise((resolve) => setTimeout(resolve, 10000)); attempts++; } throw new Error("Transaction monitoring timed out"); } ``` ### Retry logic for transient errors ```javascript theme={null} async function createQuoteWithRetry(params, maxRetries = 3) { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { return await createQuote(params); } catch (error) { // Rate limiting (429 with RATE_LIMITED) is retryable with backoff // Volume limits (DAILY_VOLUME_LIMIT_EXCEEDED) are NOT retryable const isRateLimited = error.status === 429 && error.code === "RATE_LIMITED"; // 424 and 5xx are transient, except 501 Not Implemented const isRetryable = error.status === 424 || (error.status >= 500 && error.status !== 501) || isRateLimited; if (!isRetryable || attempt === maxRetries) { throw error; } // Use Retry-After header if present, otherwise exponential backoff const retryAfter = error.headers?.["retry-after"]; const delay = retryAfter ? parseInt(retryAfter, 10) * 1000 : Math.pow(2, attempt) * 1000; await new Promise((resolve) => setTimeout(resolve, delay)); } } } ``` ### Handle webhook failures ```javascript theme={null} app.post("/webhooks/grid", async (req, res) => { try { await processWebhook(req.body); res.status(200).json({ received: true }); } catch (error) { console.error("Webhook processing error:", error); // For pending payments, default to async processing if (req.body.transaction?.status === "PENDING") { // Queue for retry await queueWebhookForRetry(req.body); return res.status(202).json({ message: "Queued for processing" }); } // For other webhooks, acknowledge receipt res.status(200).json({ received: true }); } }); ``` ## Error recovery strategies > [tab] Quote Expiration > > Automatically create a new quote when one expires: > > ```javascript theme={null} > async function executeQuoteSafe(quoteId, originalParams) { > try { > return await fetch( > `https://api.lightspark.com/grid/2025-10-13/quotes/${quoteId}/execute`, > { > method: "POST", > headers: { Authorization: `Basic ${credentials}` }, > } > ); > } catch (error) { > if (error.status === 409 && error.code === "QUOTE_EXPIRED") { > // Create new quote with same parameters > const newQuote = await createQuote(originalParams); > > // Execute immediately > return await fetch( > `https://api.lightspark.com/grid/2025-10-13/quotes/${newQuote.id}/execute`, > { > method: "POST", > headers: { Authorization: `Basic ${credentials}` }, > } > ); > } > > throw error; > } > } > ``` > [tab] Insufficient Balance > > Notify users and suggest funding: > > ```javascript theme={null} > async function handleInsufficientBalance(customerId, requiredAmount) { > const accounts = await getInternalAccounts(customerId); > const account = accounts.data[0]; > > const shortfall = requiredAmount - account.balance.amount; > > // Notify customer > await sendNotification(customerId, { > type: "INSUFFICIENT_BALANCE", > message: `You need ${formatAmount( > shortfall > )} more to complete this payment`, > action: { > label: "Add Funds", > url: "/deposit", > }, > }); > > // Return funding instructions > return { > error: "INSUFFICIENT_BALANCE", > currentBalance: account.balance.amount, > requiredAmount, > shortfall, > fundingInstructions: account.fundingPaymentInstructions, > }; > } > ``` > [tab] Network Errors > > Implement retry with exponential backoff: > > ```javascript theme={null} > async function fetchWithRetry(url, options, maxRetries = 3) { > for (let i = 0; i < maxRetries; i++) { > try { > const response = await fetch(url, options); > > if (!response.ok) { > const error = await response.json(); > throw error; > } > > return await response.json(); > } catch (error) { > const isLastAttempt = i === maxRetries - 1; > const isNetworkError = > error.code === "ECONNRESET" || > error.code === "ETIMEDOUT" || > error.status === 500; > > if (isLastAttempt || !isNetworkError) { > throw error; > } > > // Wait before retry (exponential backoff) > const delay = Math.pow(2, i) * 1000; > await new Promise((resolve) => setTimeout(resolve, delay)); > } > } > } > ``` ## User-friendly error messages Convert technical errors to user-friendly messages: ```javascript theme={null} function getUserFriendlyMessage(error) { const errorMessages = { QUOTE_EXPIRED: "Exchange rate expired. Please try again.", QUOTE_RATE_UNAVAILABLE: "Exchange rate is no longer available. Please try again.", INSUFFICIENT_BALANCE: "You don't have enough funds for this payment.", INVALID_BANK_ACCOUNT: "Bank account details are invalid. Please check and try again.", BANK_ACCOUNT_VALIDATION_PENDING: "We're still verifying this bank account. This usually takes a few business days.", PAYMENT_APPROVAL_TIMED_OUT: "Payment approval timed out. Please try again.", AMOUNT_OUT_OF_RANGE: "Payment amount is too high or too low.", INVALID_CURRENCY: "This currency is not supported.", WEBHOOK_ENDPOINT_NOT_SET: "Payment receiving is not configured. Contact support.", TRANSACTION_SIZE_LIMIT_EXCEEDED: "This amount exceeds the maximum single transaction limit. Try a smaller amount.", DAILY_VOLUME_LIMIT_EXCEEDED: "Daily transfer limit reached. Please try again tomorrow.", }; return ( errorMessages[error.code] || error.message || "An unexpected error occurred. Please try again or contact support." ); } // Usage try { await createQuote(params); } catch (error) { const userMessage = getUserFriendlyMessage(error); showErrorToUser(userMessage); } ``` ## Logging and monitoring Implement comprehensive error logging: ```javascript theme={null} class PaymentErrorLogger { static async logError(error, context) { const errorLog = { timestamp: new Date().toISOString(), errorCode: error.code, errorMessage: error.message, httpStatus: error.status, context: { customerId: context.customerId, transactionId: context.transactionId, operation: context.operation, }, stackTrace: error.stack, }; // Log to your monitoring service await logToMonitoring(errorLog); // Alert on critical errors if (error.status >= 500 || error.code === "WEBHOOK_DELIVERY_ERROR") { await sendAlert({ severity: "high", message: `Payment error: ${error.code}`, details: errorLog, }); } return errorLog; } } // Usage try { await executeQuote(quoteId); } catch (error) { await PaymentErrorLogger.logError(error, { customerId: "Customer:123", operation: "executeQuote", }); throw error; } ``` ## Best practices > [accordion] Always validate before API calls > > Validate data on your side before making API requests to catch errors early: > > ```javascript theme={null} > function validateTransferRequest(request) { > const errors = []; > > if (!request.source?.accountId) { > errors.push("Source account ID is required"); > } > > if (!request.destination?.accountId) { > errors.push("Destination account ID is required"); > } > > if (!request.amount || request.amount <= 0) { > errors.push("Amount must be greater than 0"); > } > > if (errors.length > 0) { > throw new ValidationError(errors.join(", ")); > } > > return true; > } > ``` > [accordion] Implement idempotency > > Store transaction IDs to prevent duplicate submissions on retry: > > ```javascript theme={null} > const processedTransactions = new Set(); > > async function createTransferIdempotent(params) { > const idempotencyKey = generateKey(params); > > if (processedTransactions.has(idempotencyKey)) { > throw new Error("Transaction already processed"); > } > > try { > const result = await createQuote(params); > processedTransactions.add(idempotencyKey); > return result; > } catch (error) { > // Don't mark as processed on error > throw error; > } > } > ``` > [accordion] Set appropriate timeouts > > Configure timeouts for long-running operations: > > ```javascript theme={null} > async function executeWithTimeout(promise, timeoutMs = 30000) { > const timeout = new Promise((_, reject) => > setTimeout(() => reject(new Error("Operation timed out")), timeoutMs) > ); > > return Promise.race([promise, timeout]); > } > > // Usage > try { > const result = await executeWithTimeout( > executeQuote(quoteId), > 30000 // 30 seconds > ); > } catch (error) { > if (error.message === "Operation timed out") { > // Handle timeout specifically > console.log("Quote execution timed out, checking status..."); > const transaction = await checkTransactionStatus(quoteId); > } > } > ``` ## Next steps ![](/images/icons/paper-plane-top-right.svg) Send Payments Learn how to send payments from internal accounts [Open](/payouts-and-b2b/payment-flow/send-payment) ![](/images/icons/file-text.svg) List Transactions Query and filter payment history [Open](/payouts-and-b2b/payment-flow/list-transactions) ![](/images/icons/receipt-check.svg) Reconciliation Match payments with your internal systems [Open](/payouts-and-b2b/payment-flow/reconciliation) # List Transactions Source: https://agents-docs.lightspark.com/payouts-and-b2b/payment-flow/list-transactions Query and filter payment history with powerful filtering and pagination options Retrieve transaction history with flexible filtering options. Transactions are returned in descending order (most recent first) with a default limit of 20 per page. ```bash cURL theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/transactions' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" { "data": [ { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000030", "status": "COMPLETED", "type": "OUTGOING", "direction": "DEBIT", "source": { "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", "currency": "USD" }, "destination": { "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123", "currency": "EUR" }, "sentAmount": { "amount": 10000, "currency": { "code": "USD", "symbol": "$", "decimals": 2 } }, "receivedAmount": { "amount": 9200, "currency": { "code": "EUR", "symbol": "€", "decimals": 2 } }, "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "platformCustomerId": "customer_12345", "description": "Payment for services - Invoice #1234", "exchangeRate": 0.92, "settledAt": "2025-10-03T15:30:00Z", "createdAt": "2025-10-03T15:00:00Z" } ], "hasMore": true, "nextCursor": "eyJpZCI6IlRyYW5zYWN0aW9uOjAxOTU0MmY1LWIzZTctMWQwMi0wMDAwLTAwMDAwMDAwMDAzMCJ9", "totalCount": 45 } ``` ## Filtering transactions Use query parameters to filter and narrow down transaction results. ### Filter by customer Get all transactions for a specific customer: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/transactions?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Or use your platform's customer ID: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/transactions?platformCustomerId=customer_12345' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ### Filter by transaction type Get only incoming or outgoing transactions. A transaction's type is keyed on its destination — anything landing in an internal account is `INCOMING`, and anything landing in an external account or at a UMA address is `OUTGOING`. See [Transaction Lifecycle](/platform-overview/core-concepts/transaction-lifecycle#incoming-or-outgoing) for the full table. > [tab] Outgoing Only > > ```bash theme={null} > curl -X GET 'https://api.lightspark.com/grid/2025-10-13/transactions?type=OUTGOING' \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" > ``` > [tab] Incoming Only > > ```bash theme={null} > curl -X GET 'https://api.lightspark.com/grid/2025-10-13/transactions?type=INCOMING' \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" > ``` ### Filter by status Get transactions in a specific status: ```bash theme={null} # Get all pending transactions curl -X GET 'https://api.lightspark.com/grid/2025-10-13/transactions?status=PENDING' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" # Get all completed transactions curl -X GET 'https://api.lightspark.com/grid/2025-10-13/transactions?status=COMPLETED' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Available statuses:** * `PENDING` - Transaction initiated, awaiting processing * `PROCESSING` - Transaction in progress * `COMPLETED` - Transaction successfully completed * `FAILED` - Transaction failed * `EXPIRED` - Quote expired before execution ### Filter by date range Get transactions within a specific date range: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/transactions?startDate=2025-10-01T00:00:00Z&endDate=2025-10-31T23:59:59Z' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [!TIP] > > Dates must be in ISO 8601 format (e.g., `2025-10-03T15:00:00Z`). ### Filter by account Get transactions for a specific account: ```bash theme={null} # Any transactions involving this account (sent or received) curl -X GET 'https://api.lightspark.com/grid/2025-10-13/transactions?accountIdentifier=InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" # Only transactions sent FROM this account curl -X GET 'https://api.lightspark.com/grid/2025-10-13/transactions?senderAccountIdentifier=InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" # Only transactions sent TO this account curl -X GET 'https://api.lightspark.com/grid/2025-10-13/transactions?receiverAccountIdentifier=ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ## Combining filters You can combine multiple filters to narrow down results: ```bash theme={null} # Get completed outgoing transactions for a customer in October 2025 curl -X GET 'https://api.lightspark.com/grid/2025-10-13/transactions?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001&type=OUTGOING&status=COMPLETED&startDate=2025-10-01T00:00:00Z&endDate=2025-10-31T23:59:59Z' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ## Pagination Handle large result sets with cursor-based pagination: **1. Get first page** ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/transactions?limit=20' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Response: ```json theme={null} { "data": [ /* 20 transactions */ ], "hasMore": true, "nextCursor": "eyJpZCI6IlRyYW5z...", "totalCount": 150 } ``` **2. Get next page** Use the `nextCursor` from the previous response: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/transactions?limit=20&cursor=eyJpZCI6IlRyYW5z...' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **3. Continue until done** Keep paginating while `hasMore` is `true`: ```javascript theme={null} async function getAllTransactions() { const allTransactions = []; let cursor = null; let hasMore = true; while (hasMore) { const url = cursor ? `https://api.lightspark.com/grid/2025-10-13/transactions?limit=100&cursor=${cursor}` : `https://api.lightspark.com/grid/2025-10-13/transactions?limit=100`; const response = await fetch(url, { headers: { Authorization: `Basic ${credentials}` }, }); const { data, hasMore: more, nextCursor } = await response.json(); allTransactions.push(...data); hasMore = more; cursor = nextCursor; } return allTransactions; } ``` > [!NOTE] > > The maximum `limit` is 100 transactions per request. Default is 20. ## Get a single transaction Retrieve details for a specific transaction by ID: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/transactions/Transaction:019542f5-b3e7-1d02-0000-000000000030' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ## Best practices > [accordion] Use pagination for large datasets > > Always implement pagination when fetching transactions to avoid timeouts and memory issues: > > ```javascript theme={null} > // Good: Paginated approach > async function fetchWithPagination(filters) { > const transactions = []; > let cursor = null; > > do { > const page = await fetchTransactionPage(filters, cursor); > transactions.push(...page.data); > cursor = page.nextCursor; > } while (cursor); > > return transactions; > } > > // Bad: Trying to fetch everything at once > async function fetchAll() { > const response = await fetch("/transactions?limit=10000"); // Don't do this! > return response.json(); > } > ``` > [accordion] Cache results when appropriate > > For data that doesn't change frequently (like completed transactions), implement caching: > > ```javascript theme={null} > const cache = new Map(); > > async function getCompletedTransactions(customerId, month) { > const cacheKey = `${customerId}-${month}`; > > if (cache.has(cacheKey)) { > return cache.get(cacheKey); > } > > const transactions = await fetchMonthlyTransactions(customerId, month); > cache.set(cacheKey, transactions); > > return transactions; > } > ``` > [accordion] Use specific filters to reduce result sets > > Apply filters to get only the data you need: > > ```javascript theme={null} > // Good: Specific filters > const recentFailed = await fetch( > "/transactions?status=FAILED&startDate=2025-10-01T00:00:00Z&type=OUTGOING" > ); > > // Bad: Fetch everything and filter in code > const all = await fetch("/transactions"); > const filtered = all.data.filter( > (tx) => > tx.status === "FAILED" && > tx.type === "OUTGOING" && > new Date(tx.createdAt) > new Date("2025-10-01") > ); > ``` > [accordion] Handle rate limits gracefully > > Implement exponential backoff for rate limit errors: > > ```javascript theme={null} > async function fetchWithRetry(url, retries = 3) { > for (let i = 0; i < retries; i++) { > const response = await fetch(url, { > headers: { Authorization: `Basic ${credentials}` }, > }); > > if (response.status === 429) { > const retryAfter = parseInt( > response.headers.get("Retry-After") || "1", > 10 > ); > await new Promise((resolve) => setTimeout(resolve, retryAfter * 1000)); > continue; > } > > return response.json(); > } > > throw new Error("Max retries exceeded"); > } > ``` ## Next steps ![](/images/icons/paper-plane-top-right.svg) Send Payments Learn how to send payments from internal accounts [Open](/payouts-and-b2b/payment-flow/send-payment) ![](/images/icons/receipt-check.svg) Reconciliation Match payments with your internal accounting systems [Open](/payouts-and-b2b/payment-flow/reconciliation) ![](/images/icons/square-info.svg) Error Handling Handle transaction failures and errors [Open](/payouts-and-b2b/payment-flow/error-handling) # Receipts Source: https://agents-docs.lightspark.com/payouts-and-b2b/payment-flow/receipts Send a compliant receipt to your customer for every completed transaction If you're an **unregulated platform** moving funds using Grid's money transmitter licenses, Grid (operating as Lightspark Payments, LLC) is the licensed money transmitter on your transactions. That license requires a receipt to be delivered to the customer for every completed transaction. Because you own the customer relationship, at the end of each transaction you'll need to deliver the transaction receipt. This guide explains when to send a receipt, what it must contain, how to map Grid transaction data to each receipt field, and how to confirm delivery back to Grid. > [!WARNING] > > Receipts are **required for regulatory compliance** — they're not an optional best practice. Send a receipt to your customer for **every** completed transaction, and confirm delivery to Grid. Your platform agreement obligates you to deliver receipts and to furnish records of delivery on request. ## Sample receipt layout Your receipt's visual design is up to you, but the content and field set are fixed. The layout below shows an example with sample data filled in: Your Logo Completed 06 / 10 / 2026 14:32:08 PDT Total charged \$502.50 USD Total received 500.00 USDC Sender Marcus Chen Recipient Sofía Herrera Transaction ID 019542f5-b3e7-1d02-0000-000000000030 On-chain details Transaction Hash 0x8f3a4c2e91b07d6f5a8e2c4b9d1f0a73e6c5b8d2f4a1e9c7b3d0f6a2e8c4b1d9 VC Address(es) From  0x6A2e8C4b1D9f3A4c2E91b07d6F5a8E2c4B9d1F0a To      0x4B9d1F0a73E6c5B8d2F4a1E9c7b3D0f6A2e8C4b1 Amounts Total \$502.50 Exchange Rate 1 USDC = 1.00 USD To report fraud or suspected fraud in connection with the money transmission services, please call customer service toll-free at (855) 516-0103. Refund Policy You may cancel for a full refund within 30 minutes of payment, unless the funds have already been picked up or deposited. See the [refund policy](https://support.lightspark.com/hc/en-us/categories/51347651720859-Cards) or call the number above. Recipient may receive less than the total to recipient due to fees charged by the recipient's bank and any foreign taxes. *(Foreign remittance only.)* Lightspark Payments, LLC  ·  NMLS ID 2429193 8605 Santa Monica Blvd, PMB 64461, West Hollywood, CA 90069 [www.lightspark.com](http://www.lightspark.com)  ·  (855) 516-0103 ## When to send a receipt Trigger receipt generation off the transaction completion event: * **`OUTGOING_PAYMENT.COMPLETED`** — a send (the customer is the sender). * **`INCOMING_PAYMENT.COMPLETED`** — a receive (the customer is the recipient). Send the receipt to your customer as soon as the transaction completes — by email, in-app notification, or any channel that lets the customer **retain** the receipt (for example, a downloadable PDF or an email they can save). > [!NOTE] > > Only completed transactions get a receipt. Transactions that end in `FAILED` or `EXPIRED` do not. Once you send a receipt, **freeze its contents** — if the transaction is later reversed or refunded, the original receipt stays as issued and the reversal is tracked separately on the transaction record. ## What a receipt must contain A compliant receipt combines **Lightspark's regulatory disclosures** (fixed values and legal language) with **data-driven fields** populated from the specific transaction. ### Lightspark regulatory disclosures Include these exactly as written on every receipt: | Field | Value | | - | - | | Money transmitter | Lightspark Payments, LLC | | NMLS ID | 2429193 | | Regulatory address | 8605 Santa Monica Blvd, PMB 64461, West Hollywood, CA 90069 | | Website | [www.lightspark.com](http://www.lightspark.com) | | Customer service phone | (855) 516-0103 | | Fraud reporting | "To report fraud or suspected fraud in connection with the money transmission services, please call customer services toll-free at (855) 516-0103." | | Refund policy | Link to Lightspark's refund policy: [https://support.lightspark.com/hc/en-us/categories/51347651720859-Cards](https://support.lightspark.com/hc/en-us/categories/51347651720859-Cards) | ### Transaction fields | Field | Description | Required when | | - | - | - | | Sender | Name of the customer initiating the transaction | Always | | Recipient | Name of the recipient receiving funds | Always | | Transaction ID | Unique ID per transaction | Always | | Transaction type | Cross-border send/receive, on-ramp, off-ramp, or transfer out; for virtual currency transactions, the currency type | Always | | Transaction date | Date the transaction was funded | Always | | Transaction time | Precise time including time zone | Always | | Transfer amount | Amount in the currency the transmission was funded in | Always | | Total to recipient | Amount in the currency the recipient receives | Always | | Total transfer fees | All fees charged in connection with the transaction | Always | | Taxes | Itemized taxes | When taxes apply | | Total | Total cost to the sender | Always | | Exchange rate | FX rate applied, rounded to 2–4 decimal places | When the funding and receiving currencies differ | | Transaction hash | On-chain transaction hash | Crypto transactions only | | Virtual currency address(es) | Public on-chain addresses involved | Crypto transactions only | | FX shortfall disclosure | "Recipient may receive less due to fees charged by the recipient's bank and foreign taxes." | Foreign remittance transactions only | ## Mapping Grid data to receipt fields Most receipt fields come directly from the [transaction object](/api-reference/transactions/get-transaction-by-id) — available on the completion webhook payload or by retrieving the transaction. All amounts are integers in the smallest unit of their currency (for example, cents), so format them using the currency's `decimals`. | Receipt field | Grid source | | - | - | | Transaction ID | `id` | | Transaction type | `type` (`OUTGOING` / `INCOMING`) plus the funding currency | | Transaction date / time | `settledAt` | | Sender | `customerId` / `platformCustomerId` (outgoing) or `counterpartyInformation` (incoming) | | Recipient | Destination external account beneficiary — resolve `destination.accountId` to the external account and use its beneficiary (from `accountInfo`) or `customerId` (outgoing); `customerId` / `platformCustomerId` (incoming) | | Transfer amount | `sentAmount.amount` + `sentAmount.currency` | | Total to recipient | `receivedAmount.amount` + `receivedAmount.currency` | | Total transfer fees | `fees` (smallest unit of the sending currency) | | Exchange rate | `exchangeRate` | | Transaction hash | `source.railDetails` / `destination.railDetails` (`transactionHash` + `network`) when that side's `paymentRail` is `ONCHAIN`; `reconciliationInstructions.transactionHash` carries the internal UMA settlement-leg hash | | VC address(es) | Resolve `destination.accountId` to the external account and read the wallet address from its `accountInfo` | ## Example: send a receipt on completion Listen for the completion webhook, verify its signature, assemble the receipt fields, and send the receipt to your customer. See [Webhooks](/payouts-and-b2b/platform-tools/webhooks) for signature verification. ```javascript theme={null} // Inside your verified webhook handler app.post('/webhooks/grid', async (req, res) => { // ... verify the X-Grid-Signature header first (see Webhooks) ... const event = req.body; if (event.type === 'OUTGOING_PAYMENT.COMPLETED' || event.type === 'INCOMING_PAYMENT.COMPLETED') { const tx = event.data; const format = (money) => (money.amount / 10 ** money.currency.decimals).toFixed(money.currency.decimals); const receipt = { // Lightspark regulatory disclosures (fixed) moneyTransmitter: 'Lightspark Payments, LLC', nmlsId: '2429193', address: '8605 Santa Monica Blvd, PMB 64461, West Hollywood, CA 90069', customerServicePhone: '(855) 516-0103', // Transaction fields transactionId: tx.id, transactionType: tx.type, transactionDateTime: tx.settledAt, transferAmount: `${tx.sentAmount.currency.symbol}${format(tx.sentAmount)}`, totalToRecipient: `${tx.receivedAmount.currency.symbol}${format(tx.receivedAmount)}`, totalTransferFees: tx.fees, exchangeRate: tx.exchangeRate, // include when currencies differ }; await sendReceiptEmail(tx.platformCustomerId, receipt); // your delivery channel // Tell Grid the receipt was delivered await confirmReceiptDelivery(tx.id); } res.status(200).json({ received: true }); }); ``` ## Confirm delivery to Grid After you deliver the receipt to your customer, confirm it with the [Confirm receipt delivery](/api-reference/transactions/confirm-receipt-delivery) endpoint. Grid stores the confirmation timestamp on the transaction's `receiptDeliveryConfirmedAt` field so it can be furnished to regulators on request. ```bash cURL theme={null} curl -X POST 'https://api.lightspark.com/grid/2025-10-13/transactions/{transactionId}/confirm' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H 'Content-Type: application/json' \ -d '{ "receiptDeliveryConfirmedAt": "2025-10-03T15:31:00Z" }' ``` > [!NOTE] > > `receiptDeliveryConfirmedAt` is optional — if you omit it, Grid records the current server time. Calling the endpoint again for the same transaction updates the stored confirmation time. # What Recipients See Source: https://agents-docs.lightspark.com/payouts-and-b2b/payment-flow/recipient-descriptors The sender name and payment reference a recipient sees on their bank statement, by rail and corridor Recipients need to know who paid them and why. What they see on their bank statement depends on two things: * **The sender name.** This comes from the account the money leaves. On US rails and some other corridors that's the account holder, and on others it's a fixed Lightspark entity. You can't set it per payment. * **The payment reference.** You set this on each quote with `remittanceInformation`, and on ACH also with `statementDescriptor`. Grid puts it in the right field for the rail. On every corridor, the recipient gets at least a payment reference or a clearing ID to identify the payment. ## Fields you control Set these on `POST /quotes`. Both are optional, but send them on every payout. | Field | Rails | Limit | Where it appears | | - | - | - | - | | `remittanceInformation` | All bank rails | 80 printable ASCII characters. Some rails take fewer (see below) | ACH addenda record; RTP and FedNow remittance information; wire OBI (Originator to Beneficiary Information); the payment reference on other local rails | | `statementDescriptor` | ACH only | 10 printable ASCII characters | The ACH Company Entry Description, the short label shown next to the sender name on the recipient's statement | ```json theme={null} { "statementDescriptor": "ABC CORP", "remittanceInformation": "ABC Corp invoice 4471" } ``` > [!NOTE] > > `statementDescriptor` is rejected with `400 INVALID_INPUT` on a quote that moves no ACH entry, > so leave it out for non-ACH destinations. When you omit it on ACH, Grid uses its default. If you leave `remittanceInformation` empty on a cross-border payout, Grid may fill the reference with the sending customer's name: the company legal name for a business, or the first and last name for an individual. Don't rely on this. Set the reference yourself. ## Sending from US accounts On US rails, **the recipient sees the account holder name of the account the payout leaves**. Depending on how your platform is configured, that's either your platform's own account or an account held by your customer: * **Payouts from your customer's account** show your customer's name as the sender. * **Payouts from your platform's account** show your platform's name as the sender. Add your customer's name in the payment reference. The name you give each internal account in Grid is for your own reconciliation. Recipients don't see it. For example, your platform, Acme Pay Inc, pays Ben from its own account on behalf of your customer ABC Corp: | Rail | Sender shown | Where your customer's name goes | What Ben may see | | - | - | - | - | | ACH | Account holder name (up to 16 characters) | `statementDescriptor` (10 characters) | `ORIG CO NAME:ACME PAY INC CO ENTRY DESCR:ABC CORP` | | RTP / FedNow | Account holder name | `remittanceInformation` (up to 80 characters) | `Acme Pay Inc`, plus `ABC Corp invoice 4471` in full | | Wire | Account holder name and address | `remittanceInformation`, sent as the OBI | `Acme Pay Inc`, plus `ABC Corp invoice 4471` | If the payout leaves an account held by ABC Corp instead, Ben sees `ABC CORP` as the sender, and you can use the descriptor and reference for your own invoice details. Outbound wires aren't enabled for every platform. Contact Lightspark if you need them. Each bank decides what to display. Some show every field, and others shorten or hide names, so treat these examples as the best case. On ACH, the 10-character descriptor is the field banks show most reliably, so lead with your customer's name there. To set references when you pay out from your platform's account on a customer's behalf: **1. Send from the account tied to that customer** Send each payout from the internal account you use for that customer, so the payment is tied to them in Grid. **2. Set statementDescriptor on ACH payouts** Set `statementDescriptor` to your customer's name in capitals, 10 characters or fewer. Abbreviate long names yourself so the bank doesn't cut them off. Leave it out on RTP, FedNow, and wire payouts. Grid rejects blank values, all zeros, `MISC`, and the values NACHA reserves for system entries (`REVERSAL`, `RETURN FEE`, `AUTOENROLL`, `NONSETTLED`, `RECLAIM`) with `400 INVALID_INPUT`, in any letter case. If a customer's name matches one of these, use a different abbreviation. **3. Set remittanceInformation** Set `remittanceInformation` to the customer's name plus your reference, most important words first. Use plain letters, numbers, and punctuation. ## Sending to other countries On cross-border payouts, the sender name depends on how the corridor settles locally. Corridors fall into three groups: | Group | Sender name the recipient sees | What to put in `remittanceInformation` | | - | - | - | | Account holder name shown | The account holder name of the originating account, from the sender details on the quote | Your reference, such as an invoice number | | Fixed sender name | A Lightspark entity | Your customer's name first, then your reference | | Reference only | Set by the local clearing system, or not shown | Your customer's name first, then your reference | | Currency | Country | Sender name shown | Reference limit | | - | - | - | - | | AED | United Arab Emirates | Account holder name | 18 characters | | BDT | Bangladesh | Account holder name | 16 characters | | BRL | Brazil | Account holder name | 18 characters | | CAD | Canada | Account holder name | 18 characters | | CNY | China | Account holder name | 16 characters | | COP | Colombia | Account holder name | 16 characters | | DKK | Denmark | Account holder name | 18 characters | | EGP | Egypt | Account holder name | 16 characters | | GBP | United Kingdom | Account holder name | 18 characters | | GHS | Ghana | Account holder name | 16 characters | | GTQ | Guatemala | Account holder name | 16 characters | | HKD | Hong Kong | Account holder name | 18 characters | | HTG | Haiti | Account holder name | 16 characters | | IDR | Indonesia | Account holder name | 18 characters | | ILS | Israel | Account holder name | 16 characters | | INR | India | Account holder name | 18 characters | | JMD | Jamaica | Account holder name | 16 characters | | MYR | Malaysia | Account holder name | 18 characters | | PHP | Philippines | Account holder name | 18 characters | | PKR | Pakistan | Account holder name | 16 characters | | SGD | Singapore | Account holder name | 18 characters | | THB | Thailand | Account holder name | 18 characters | | TRY | Turkey | Account holder name | 16 characters | | USD | El Salvador | Account holder name | 16 characters | | VND | Vietnam | Account holder name | 18 characters | | USD, EUR (SWIFT) | International wires | Account holder name | 18 characters | | EUR | SEPA countries | Fixed: Lightspark Payments Europe AS | 15 letters and digits. Spaces and punctuation are removed | | MXN | Mexico | Reference only | 40 characters | | Other corridors | | Varies by corridor | Varies by corridor | The payment reference on every row is `remittanceInformation`. Each recipient bank decides how much of the sender name and reference to display. > [!TIP] > > Before you go live, contact Lightspark to confirm the sender name on the corridors you plan to > use, and send a test payment on each one. ## Best practices * **Always send `remittanceInformation`.** On fixed-name and reference-only corridors, it's the only way the recipient can tell who the payment is from. * **Put the most important words first.** Many rails shorten the reference, sometimes to 15 or 16 characters. `ACMELTD INV4471` survives shortening and `Payment for invoice 4471 from Acme Ltd` doesn't. * **Use plain letters and digits.** `remittanceInformation` accepts printable ASCII only, so remove accents before you send it (`Jose`, not `José`). Some rails, such as SEPA, then keep only letters and digits. * **Don't promise a sender name you can't control.** Tell your customers that the sender name depends on the corridor, and that the payment reference is the reliable way to identify a payment. ## Common questions **Can I change the sender name to my brand or my customer's name?** Not per payment. On US rails, the sender is the account holder name of the account the payout leaves, which is your customer when you send from their account. On fixed-name corridors, it's the Lightspark entity that settles the payout. **What if I leave `remittanceInformation` empty?** On cross-border payouts, Grid may use the sending customer's name as the reference. On US rails, the reference is left empty and only the sender name and, on ACH, the default descriptor appear. **Why does the recipient see a shorter reference than I sent?** Each rail and bank has its own length and character limits. Grid shortens the value to the rail's limit, and the recipient's bank may shorten it further. ## Related * [Sending payments](/payouts-and-b2b/payment-flow/send-payment) * [Reconciliation](/payouts-and-b2b/payment-flow/reconciliation) # Reconciliation Source: https://agents-docs.lightspark.com/payouts-and-b2b/payment-flow/reconciliation Match Grid transactions with your internal systems This guide explains how to reconcile transactions between your internal systems and the Grid API using two complementary mechanisms: real-time webhooks and periodic queries. > [!TIP] > > Use webhooks for real-time updates and daily queries as a backstop to detect missed events or data drift. ## Handling webhooks Listen for transaction webhooks and apply every status change to your records as it arrives. > [!NOTE] > > A transaction's `status` reports where the funds are now, and it can change after settlement. A bank return moves `COMPLETED` → `FAILED` and `destination.railDetails.status` to `RETURNED`; a dishonored return moves it back to `COMPLETED`. Only `EXPIRED` is terminal. Also listen for `OUTGOING_PAYMENT.REFUND_COMPLETED` and `OUTGOING_PAYMENT.REFUND_FAILED` to track refunds on failed transactions. * **Outbound transactions**: The originating account is debited at transaction creation. If the transaction ultimately fails, a refund is posted back to the originating account. * **Inbound transactions**: The receiving account is credited only on success. A failure before the credit does not change balances. A return after `COMPLETED` does: the credited funds are debited back when `INCOMING_PAYMENT.FAILED` arrives with `source.railDetails.status` `RETURNED`. > [!TIP] > > Grid retries failed webhooks up to 160 times over 7 days with exponential backoff. Use the dashboard to review and remediate webhook delivery issues. **1. Subscribe and verify signatures** Configure your webhook endpoint and verify signatures. See [Webhooks](/global-p2p/platform-tools/webhooks). Sample webhook payload: ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-0000000000ab", "type": "OUTGOING_PAYMENT.COMPLETED", "timestamp": "2025-10-03T15:30:01Z", "data": { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000030", "status": "COMPLETED", "type": "OUTGOING", "direction": "DEBIT", "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "sentAmount": { "amount": 10000, "currency": { "code": "USD", "symbol": "$", "decimals": 2 } }, "receivedAmount": { "amount": 9200, "currency": { "code": "EUR", "symbol": "€", "decimals": 2 } }, "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "platformCustomerId": "customer_12345", "createdAt": "2025-10-03T15:00:00Z", "settledAt": "2025-10-03T15:30:00Z", "description": "Payment for services - Invoice #1234" } } ``` > [!NOTE] > > When funds move over an external rail, that `source` / `destination` object may include a > `railDetails` describing the transfer. It is present for US bank rails and on-chain > transfers today. Its `paymentRail` determines the fields: an > `ONCHAIN` transfer carries `transactionHash` + `network` (e.g. `SOLANA`) to match on a block > explorer, an `ACH` transfer carries its `traceNumber`, a `WIRE` carries its `imad`, > and `RTP` / `FEDNOW` transfers carry their `endToEndId`. Rail identifiers can land shortly > after the status changes, so the webhook payload may not include them yet; retrieve the > transaction (`GET /transactions/{id}`) to read them once settled. **2. Process events idempotently** Use the envelope `id`, `data.id`, and `timestamp` to ensure idempotent handling, updating your internal ledger on each status transition. **3. Keep settled transactions open to late events** Treat `COMPLETED` and `FAILED` as settled, not final. An ACH entry can be returned for up to 60 days after it settles, and a return can be dishonored later still. Keep applying every webhook for a transaction, whenever it arrives, and adjust your records when its `status` changes. Use the daily query below to catch any event your endpoint missed. ## Reconcile via queries Additionally, you can list transactions for a time window and compare with your internal records. > [!NOTE] > > We recommend querying days from `00:00:00.000` to `23:59:59.999` in your preferred timezone. ```bash cURL theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/transactions?startDate=2025-10-01T00:00:00.000Z&endDate=2025-10-01T23:59:59.999Z&limit=100' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Response ```json theme={null} { "data": [ { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000030", "status": "COMPLETED", "type": "OUTGOING", "direction": "DEBIT", "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "sentAmount": { "amount": 10000, "currency": { "code": "USD", "symbol": "$", "decimals": 2 } }, "receivedAmount": { "amount": 9200, "currency": { "code": "EUR", "symbol": "€", "decimals": 2 } }, "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "platformCustomerId": "customer_12345", "description": "Payment for services - Invoice #1234", "exchangeRate": 0.92, "settledAt": "2025-10-03T15:30:00Z", "createdAt": "2025-10-03T15:00:00Z" } ], "hasMore": true, "nextCursor": "eyJpZCI6IlRyYW5zYWN0aW9uOjAxOTU0MmY1LWIzZTctMWQwMi0wMDAwLTAwMDAwMDAwMDAzMCJ9", "totalCount": 45 } ``` ## Troubleshooting * **Missing webhook**: Check delivery logs in the dashboard and ensure your endpoint returns `2xx`. Retries continue for 7 days. * **Mismatched balances**: Re-query the date range and compare each transaction's current `status` to your records. Outbound failures are refunded. An inbound failure before the credit changes no balance; an inbound return after `COMPLETED` debits the credited funds back, and a dishonored return (`railDetails.status` `RETURN_DISHONORED`) credits them again. * **Pagination gaps**: Always follow `nextCursor` until `hasMore` is `false`. # Sending Payments Source: https://agents-docs.lightspark.com/payouts-and-b2b/payment-flow/send-payment Learn how to send payments between accounts Send payments between internal and external accounts, with automatic exchange rate handling when the currencies differ. ## Overview Every payment goes through `POST /quotes`, whether or not the currencies differ. A quote prices the transfer — the amounts, the fees, and, when the currencies differ, the exchange rate — and creates the transaction that carries the money. What varies is when you execute it: * **In one request.** Set `immediatelyExecute` and Grid creates and executes the quote together. Use this when you don't need to put rate or fee details in front of your user before the money moves. * **In two steps.** Create the quote, show your user what the transfer will cost, then call execute before the quote expires. Use this whenever your UX surfaces rates or fees — which includes same-currency transfers, where there is no exchange rate but there can still be fees worth showing. Either way the request shape is the same, and the payment rail is chosen from the destination account. The same endpoint sends to UMA addresses by giving the quote a `UMA_ADDRESS` destination. See [Sending payments](/global-p2p/sending-receiving-payments/sending-payments) for that flow. ## Prerequisites Before sending payments, ensure you have: * An active internal account with sufficient balance * A verified external account for the destination * Valid API credentials with appropriate permissions * A webhook endpoint configured to receive payment status updates (recommended) > [!TIP] > > If you don't have these set up yet, review the [Internal > Accounts](/payouts-and-b2b/depositing-funds/internal-accounts) and [External > Accounts](/payouts-and-b2b/depositing-funds/external-accounts) guides first. ## Checking limits before you quote Every corridor accepts amounts only within a range. You can read that range before you create a quote, so an out-of-range amount surfaces in your own UI rather than as a `400 AMOUNT_OUT_OF_RANGE` on `POST /quotes`. Which endpoint you use depends on what you know: | You know | Use | Bounds you get | | - | - | - | | A currency corridor | `GET /exchange-rates` | `minSendingAmount` / `maxSendingAmount`, in the sending currency | | A specific recipient | `GET /receiver/external-account/{accountId}` or `GET /receiver/uma/{address}` | Per-currency `min` / `max` in the receiving currency, plus sending-side bounds where Grid can resolve them | > [!WARNING] > > These bounds are approximate. They are priced off a cached rate that refreshes about every > five minutes, and only `POST /quotes` locks a rate and a final amount. Treat what you read > here as a pre-flight check for your UI, not a guarantee that the quote will succeed. ### Across a corridor Use the exchange rates endpoint when you know the currencies but not yet the recipient — populating a currency picker, or validating an amount as the sender types it. ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/exchange-rates?sourceCurrency=USD&destinationCurrency=INR' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ```json Success (200 OK) theme={null} { "data": [ { "sourceCurrency": { "code": "USD", "name": "US Dollar", "symbol": "$", "decimals": 2 }, "sendingAmount": 10000, "minSendingAmount": 100, "maxSendingAmount": 10000000, "destinationCurrency": { "code": "INR", "name": "Indian Rupee", "symbol": "₹", "decimals": 2 }, "destinationPaymentRail": "UPI", "receivingAmount": 825000, "exchangeRate": 0.012121, "fees": { "fixed": 100, "total": 150 }, "updatedAt": "2025-02-05T12:00:00Z" } ] } ``` `minSendingAmount` and `maxSendingAmount` are in the smallest unit of `sourceCurrency` — on this corridor, \$1.00 to \$100,000.00. Omit `destinationCurrency` to get every corridor available from a source currency, each with its own bounds and rail. Repeat the parameter to compare a few: `?sourceCurrency=USD&destinationCurrency=INR&destinationCurrency=GBP`. > [!TIP] > > Pass `sendingAmount` to price a specific amount. `fees.total` varies with the amount sent, > so the default (`10000`) is only representative. ### For a specific recipient Once you have a destination, look it up. The lookup prices the corridor against that particular recipient and returns a `lookupId` you can carry into the quote. ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/receiver/external-account/ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001&sendingCurrency=USD' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ```json Success (200 OK) theme={null} { "accountId": "ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", "lookupId": "Lookup:019542f5-b3e7-1d02-0000-000000000009", "sendingCurrency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 }, "supportedCurrencies": [ { "currency": { "code": "MXN", "name": "Mexican Peso", "symbol": "$", "decimals": 2 }, "estimatedExchangeRate": 18.5, "min": 2000, "max": 185000000, "minSendingAmount": 109, "maxSendingAmount": 10000000 } ], "requiredPayerDataFields": [ { "name": "FULL_NAME", "mandatory": true } ] } ``` Each entry carries bounds on both sides of the conversion. `min` and `max` are in the smallest unit of that entry's `currency` — here MX\$20.00 to MX\$1,850,000.00. `minSendingAmount` and `maxSendingAmount` are in the smallest unit of the response's `sendingCurrency` — \$1.09 to \$100,000.00 — which is the pair you want when your UI collects an amount in the sender's currency. Pass `sendingCurrency` when your customer holds more than one currency. Without it the bounds are priced against the customer's default currency, which may not be the one they intend to send from. `GET /receiver/uma/{address}` returns the same shape for a UMA recipient, with `receiverUmaAddress` in place of `accountId`. A UMA recipient commonly supports several currencies, so expect more than one entry in `supportedCurrencies`, each with its own bounds. > [!NOTE] > > `minSendingAmount` and `maxSendingAmount` are omitted when Grid cannot resolve a > sending-side bound for that currency. Fall back to dividing `min` and `max` by > `estimatedExchangeRate`, and treat the result as looser than the real limit — the sending > leg can impose a bound of its own that the converted receiving bound doesn't reflect. Reuse the `lookupId` on `POST /quotes` to price against the same lookup. It is required for UMA destinations. > [!NOTE] > > Clearing these bounds doesn't guarantee the quote succeeds. Cumulative limits are enforced > at quote time and surface separately as `DAILY_VOLUME_LIMIT_EXCEEDED` (HTTP 429), and a > per-transaction ceiling can surface as `TRANSACTION_SIZE_LIMIT_EXCEEDED`. Handle both > alongside `AMOUNT_OUT_OF_RANGE`. ## Send a payment **1. Get account IDs** Retrieve the internal account (source) and external account (destination) IDs: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Note the `id` fields from both the internal and external accounts you want to use. **2. Create the quote** Specify the source and destination accounts and the amount to lock: ```bash cURL 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:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123", "paymentRail": "ACH" }, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 10000, "remittanceInformation": "INV-12345", "statementDescriptor": "INVOICE", "description": "Payment for services - Invoice #1234" }' ``` ```json Success (200 OK) theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000025", "status": "PENDING", "createdAt": "2025-10-03T15:00:00Z", "expiresAt": "2025-10-03T15:15:00Z", "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "sendingCurrency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 }, "receivingCurrency": { "code": "EUR", "name": "Euro", "symbol": "€", "decimals": 2 }, "totalSendingAmount": 10000, "totalReceivingAmount": 9200, "exchangeRate": 0.92, "feesIncluded": 50, "platformFeesIncluded": 0, "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000030", "description": "Payment for services - Invoice #1234" } ``` > [!NOTE] > > **Same-currency transfers use this exact request.** The two currencies simply match, and > the quote comes back with an `exchangeRate` of `1` — the fee fields are still populated. > Add `"immediatelyExecute": true` to create and execute in this one request and skip the > next two steps. > [!NOTE] > > **Locked currency side** determines which amount is fixed: > > * `SENDING`: Lock the sending amount (receiving amount calculated based on exchange rate) > * `RECEIVING`: Lock the receiving amount (sending amount calculated based on exchange rate) > [!TIP] > > The `paymentRail` field is optional. If omitted, Grid selects a default rail for the destination. Specify a rail (e.g., `ACH`, `ACH_SAME_DAY`, `WIRE`, `RTP`, `FEDNOW`) when you need to control which payment network processes the transfer. `ACH_SAME_DAY` is never selected for you: it requests same-business-day settlement and is priced separately. It is capped at $1,000,000 per entry by the NACHA same-day limit (rising to $10,000,000 on 2027-09-17); a payout above that is rejected, so send it over `ACH` instead. > [!NOTE] > > `remittanceInformation` is optional. Use it to send a reference that travels with the payment to the recipient (max 80 characters). This populates the ACH Addenda record, FedNow/RTP remittance information, or wire OBI field depending on the payment rail. `statementDescriptor` is optional and used only on ACH: it sets the up-to-10-character Company Entry Description the recipient's bank shows on their statement, such as `PAYROLL`. > [!NOTE] > > `purposeOfPayment` is optional. Some destinations require it, and some rails carry it on > the payment itself. A business payout to China also needs supporting documents for its > purpose. See [Supporting documents](#supporting-documents). > [!NOTE] > > Amounts are specified in the smallest unit of the currency (cents for USD, pence for GBP, etc.). For example, `12550` represents \$125.50 USD. **3. Review the quote** Before executing, check that: * The exchange rate is acceptable * Fees are as expected * The receiving amount meets requirements * The quote hasn't expired (check `expiresAt`) > [!WARNING] > > Quote expiration depends on the corridor but is typically \~5 minutes or greater. If expired, create a new quote to get an updated exchange rate. > [!NOTE] > > Skip this step by setting `immediatelyExecute` on the quote. A same-currency quote has no > exchange rate to review, but check `feesIncluded` if your UX shows the customer what the > transfer costs. **4. Execute the quote** Confirm and execute the quote to initiate the transfer: ```bash cURL theme={null} curl -X POST 'https://api.lightspark.com/grid/2025-10-13/quotes/Quote:019542f5-b3e7-1d02-0000-000000000025/execute' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` The quote comes back with `status` `PROCESSING` and the same `transactionId` it carried at creation — unless the customer requires [Strong Customer Authentication](/platform-overview/core-concepts/quote-system), in which case it returns `PENDING_AUTHORIZATION` and the transfer waits on that. > [!TIP] > > Once executed, the quote creates a transaction and the transfer begins processing. The `transactionId` can be used to track the payment. > [!NOTE] > > **Real-time funding sources:** If your quote uses a real-time funding source (USDC, BTC, RTP, or FedNow), you don't call the execute endpoint. Instead, send a payment to the account specified in the quote's `paymentInstructions`. Grid detects the deposit and processes the transfer automatically. **5. Monitor completion** After execution, a transaction is created and progresses through `PENDING` → `PROCESSING` → `COMPLETED` or `FAILED`. You'll receive `OUTGOING_PAYMENT.` webhooks as the transaction progresses: ```json theme={null} { "type": "OUTGOING_PAYMENT.COMPLETED", "data": { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000030", "status": "COMPLETED", "type": "OUTGOING", "direction": "DEBIT", "sentAmount": { "amount": 10000, "currency": { "code": "USD", "symbol": "$", "decimals": 2 } }, "receivedAmount": { "amount": 9200, "currency": { "code": "EUR", "symbol": "€", "decimals": 2 } }, "exchangeRate": 0.92, "settledAt": "2025-10-03T15:30:00Z", "quoteId": "Quote:019542f5-b3e7-1d02-0000-000000000025" }, "timestamp": "2025-10-03T15:31:00Z" } ``` If a transaction fails, Grid initiates a refund automatically. You'll receive `OUTGOING_PAYMENT.REFUND_PENDING` followed by `OUTGOING_PAYMENT.REFUND_COMPLETED` or `OUTGOING_PAYMENT.REFUND_FAILED`. The transaction's `refund` object tracks the refund status and reference. > [!NOTE] > > For the full state diagram, refund object details, and all webhook scenarios (including bank returns and manual cancellations), see the [Transaction Lifecycle](/platform-overview/core-concepts/transaction-lifecycle) guide. ### Transaction statuses | Status | Description | | - | - | | `PENDING` | Quote created, awaiting execution | | `PROCESSING` | Quote executed, transfer in progress through the payment rail | | `COMPLETED` | Transfer successfully completed | | `FAILED` | Transfer failed — accompanied by a `failureReason`, and a refund is initiated automatically (track via the `refund` object) | | `EXPIRED` | Quote wasn't executed before the expiry window | > [!NOTE] > > For the full state diagram including refund tracking and edge cases, see the [Transaction Lifecycle](/platform-overview/core-concepts/transaction-lifecycle) guide. ## Supporting documents A business payout to China needs supporting documents, such as an invoice or a contract. That is a CNY bank transfer to an external account with `beneficiaryType: "BUSINESS"`. The payout's `purposeOfPayment` decides which documents you need. > [accordion] Which documents each purpose needs > > To create a business beneficiary account, see the China tab in > [External Accounts](/payouts-and-b2b/depositing-funds/external-accounts). Other payouts don't > take documents. A quote with `documentIds` for any other destination returns > `400 INVALID_INPUT`. > > The payout must use one of the purposes below. Any other `purposeOfPayment` returns > `400 INVALID_INPUT`, including `GOODS_OR_SERVICES` and `SERVICE_CHARGES`. A payment for > services uses the purpose that names the service. > > Each row is one document to supply. Upload one file for each requirement of your purpose. > Where a requirement lists more than one type, the types are alternatives for that one file. > Declare any one of them as the file's `documentType`. > > | Purpose | Requirement ID | Accepted document types | > | - | - | - | > | `EXPORTED_GOODS_PREPAYMENT` | `PURCHASE_ORDER` | `PURCHASE_ORDER` | > | `EXPORTED_GOODS_POSTPAYMENT` | `LOGISTICS_BILL` | `LOGISTICS_BILL` | > | | `CUSTOMS_DECLARATION` | `CUSTOMS_DECLARATION` | > | | `COMMERCIAL_AGREEMENT` | `PURCHASE_ORDER`, `INVOICE`, `CONTRACT` | > | `COMMISSION_ON_GOODS`, `COMMISSION_ON_SERVICES`, `ACCOUNTING_SERVICES`, `EXHIBITION_SERVICES` | `CONTRACT` | `CONTRACT` | > | | `INVOICE` | `INVOICE` | > | | `SUPPORTING_PROOF` | `PURCHASE_ORDER`, `DELIVERY_SLIP` | > | `OFFICE_EXPENSES` | `CONTRACT` | `CONTRACT` | > | | `INVOICE` | `INVOICE` | > | `DELIVERY_FEES` | `BILL_OF_LADING` | `BILL_OF_LADING` | > | | `CONTRACT_OR_INVOICE` | `CONTRACT`, `INVOICE` | > | `TRAVEL` | `FLIGHT_TICKET` | `FLIGHT_TICKET` | > | | `TRAVEL_DOCUMENT` | `TRAVEL_DOCUMENT` | > | | `HOTEL_BOOKING_CONFIRMATION` | `HOTEL_BOOKING_CONFIRMATION` | > | `HOTEL_ACCOMMODATION` | `HOTEL_BOOKING_CONFIRMATION` | `HOTEL_BOOKING_CONFIRMATION` | > > The four service purposes share one set of requirements. For example, an > `ACCOUNTING_SERVICES` payout needs three files: a contract, an invoice, and either a > purchase order or a delivery slip. > > > [!NOTE] > > > > The requirement ID names the document to supply. The document type names what a file is. > > `400 DOCUMENTS_REQUIRED` reports missing documents by requirement ID. > > **Limits** > > * A quote accepts at most 3 documents. > * Each file fills one requirement. A requirement that accepts several types still takes > one file. > * Each file is a PDF, JPEG, or PNG, from 1 to 8,000,000 bytes. Grid detects the format from > the file contents, not the file name. > * A document can be used for 24 hours after upload, until its `expiresAt`. > * A document can be used on one quote only. > * A document belongs to the customer in its `customerId`. Only that customer's quotes can > use it. Omit `customerId` when the platform itself is the sender. The document can then > be used only on the platform's own quotes. > [accordion] Send a payout with documents > > **1. Upload each file** > > Upload one file per request with `POST /payment-documents`. You can send the requests in > parallel. > > ```bash cURL theme={null} > curl -X POST 'https://api.lightspark.com/grid/2025-10-13/payment-documents' \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -F 'file=@invoice-2025-0142.pdf' \ > -F 'documentType=INVOICE' \ > -F 'customerId=Customer:019542f5-b3e7-1d02-0000-000000000001' > ``` > > ```json Success (201 Created) theme={null} > { > "id": "PaymentDocument:019542f5-b3e7-1d02-0000-000000000001", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "documentType": "INVOICE", > "fileName": "invoice-2025-0142.pdf", > "sizeBytes": 482133, > "contentType": "application/pdf", > "status": "UPLOADED", > "expiresAt": "2025-10-04T12:00:00Z", > "createdAt": "2025-10-03T12:00:00Z" > } > ``` > > Keep the `id` of each document. To check whether a document can still be used, call > `GET /payment-documents/{paymentDocumentId}`. > > **2. Create the quote with the document IDs** > > Pass the IDs in `documentIds`, along with the `purposeOfPayment` they support. A request > with `documentIds` must carry an `Idempotency-Key` header. > > ```bash cURL 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' \ > -H 'Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7' \ > -d '{ > "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, > "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:c34dcbd6-dced-4ec4-b756-3c3a9ea3d789" }, > "lockedCurrencySide": "RECEIVING", > "lockedCurrencyAmount": 7250000, > "purposeOfPayment": "ACCOUNTING_SERVICES", > "documentIds": [ > "PaymentDocument:019542f5-b3e7-1d02-0000-000000000001", > "PaymentDocument:019542f5-b3e7-1d02-0000-000000000002", > "PaymentDocument:019542f5-b3e7-1d02-0000-000000000003" > ], > "description": "Accounting services, invoice INV-2025-0142" > }' > ``` > > Grid attaches every document to the payment before it returns the quote. The quote lists > their IDs in its `documentIds` field. For a document's details, such as its type and its > `ATTACHED` status, call `GET /payment-documents/{paymentDocumentId}`. With > `immediatelyExecute: true`, Grid attaches the documents before it executes the quote. > Otherwise, execute the quote as in [Send a payment](#send-a-payment). > [accordion] Errors > > If `documentIds` doesn't fill every requirement of the purpose, `POST /quotes` returns > `400 DOCUMENTS_REQUIRED`. `details.missingRequirements` lists the requirement ID of each > missing document. > > ```json theme={null} > { > "code": "DOCUMENTS_REQUIRED", > "reason": "Required supporting documents are missing", > "details": { > "missingRequirements": ["SUPPORTING_PROOF"] > } > } > ``` > > Upload the missing documents, then send the quote request again with a new > `Idempotency-Key`. Treat each requirement ID as an opaque value. Grid may add new ones as > requirements change. > > Other errors on a quote with documents: > > | Error | Cause | What to do | > | - | - | - | > | `400 INVALID_INPUT` | No `Idempotency-Key`, a document whose type fills no remaining requirement, or a purpose outside the table | Fix the request and send it with a new `Idempotency-Key` | > | `404 PAYMENT_DOCUMENT_NOT_FOUND` | An ID doesn't match a document of the sending customer, or of the platform when it is the sender | Fix the IDs and send the request with a new `Idempotency-Key` | > | `409 PAYMENT_DOCUMENT_ALREADY_USED` | A document is already attached to another quote | Upload the file again and retry with the new ID | > | `410 PAYMENT_DOCUMENT_EXPIRED` | A document passed its `expiresAt` | Upload the file again and retry with the new ID | > | `424 PAYMENT_DOCUMENT_ATTACHMENT_FAILED` | Grid could not attach a document to the payment | Retry the same request with the same `Idempotency-Key` | > > A request that fails with a `409`, `410`, or `424` created no quote. A retry with the same > `Idempotency-Key` runs the request again. > [accordion] What the payout partner checks > > Grid does not check what a document says. The payout partner reviews each document after > Grid attaches it. To avoid a rejected or delayed payout: > > * Every document must carry the beneficiary's stamp. A contract must be stamped by both > parties. > > * The invoice amount must match the transaction amount. > > * The invoice currency must match the payout currency. > > * Sender and beneficiary details in the documents must match the details you send through > the API. > > > [!WARNING] > > > > A successful attachment means the payout partner received the file, not that it approved > > it. The partner may later request more information about the payment, with a deadline of > > 15 calendar days. ## Payout timing How long a payout takes is determined by the rail it settles over. Timings below are typical end-to-end times measured from quote execution: | Timing | Rails | | - | - | | **Instant** (seconds) | RTP, FedNow, SEPA Instant, UPI, IMPS, SPEI, PIX, FPS, PayNow, FAST, InstaPay, Bre-B, Mobile Money, UnionPay, internal transfers, Lightning/Spark, and crypto on Solana/Base/Polygon/Plasma/Tron | | **Minutes** | RTGS 30 min · Ethereum 15 min | | **\~10 min – 1 hour** | Bitcoin L1, depending on network congestion | | **\~1 day** | SEPA, Wire, NEFT, PesoNet, CIPS (24h) | | **\~2 days** | ACH, Bank Transfer, EFT, ACH Colombia (48h) | | **\~3 days** | SWIFT | > [!NOTE] > > These are typical times, not guarantees. Instant rails run continuously — including weekends > and holidays — but still depend on the receiving institution. The slower bank rails settle on > banking hours, so a transfer submitted after a bank's cutoff, on a weekend, or on a local > holiday starts on the next banking day. Compliance review on a given payment can extend any > of these. ## Strong Customer Authentication (EU customers) Customers in SCA-regulated regions (in practice the EU: EUR / USDC) must confirm payments with Strong Customer Authentication. When it applies, the quote comes back `PENDING_AUTHORIZATION` carrying an `scaChallenge` that you authorize before the transfer is released; for every other customer nothing changes. See [Per-transaction authorization](/platform-overview/sca/per-transaction-authorization) for the full walkthrough. ## Checking Payment Status Configure a webhook endpoint to receive real-time notifications when payment status changes: ```javascript theme={null} app.post("/webhooks/grid", (req, res) => { const { type, data } = req.body; switch (type) { case "OUTGOING_PAYMENT.COMPLETED": console.log(`Payment ${data.id} completed at ${data.settledAt}`); // Update your database, notify customer break; case "OUTGOING_PAYMENT.FAILED": console.log(`Payment ${data.id} failed: ${data.failureReason}`); // Handle failure, notify customer — refund webhook follows break; case "OUTGOING_PAYMENT.PROCESSING": console.log(`Payment ${data.id} is processing`); // Optional: Update UI to show processing state break; case "OUTGOING_PAYMENT.REFUND_COMPLETED": console.log(`Payment ${data.id} refund completed`); // Update your records with refund details break; case "OUTGOING_PAYMENT.REFUND_FAILED": console.log(`Payment ${data.id} refund failed`); // Alert your team — may require manual resolution break; } res.status(200).json({ received: true }); }); ``` > [!TIP] > > See the [Webhooks guide](/payouts-and-b2b/platform-tools/webhooks) for complete > webhook implementation details including signature verification. ## Best Practices > [accordion] Handle quote expiration gracefully > > Quote expiration depends on the corridor (typically \~5 minutes or greater). Always check expiration before executing: > > ```javascript theme={null} > async function executeQuoteWithCheck(quoteId) { > const quote = await getQuote(quoteId); > > if (new Date(quote.expiresAt) < new Date()) { > // Quote expired, create a new one > const newQuote = await createQuote({ > source: quote.source, > destination: quote.destination, > lockedCurrencySide: quote.lockedCurrencySide, > lockedCurrencyAmount: quote.lockedCurrencyAmount, > }); > > return executeQuote(newQuote.id); > } > > return executeQuote(quoteId); > } > ``` > [accordion] Include descriptive payment references > > Always include meaningful descriptions to help with reconciliation: > > ```javascript theme={null} > const description = [ > `Invoice #${invoiceId}`, > `Customer: ${customerName}`, > `Date: ${new Date().toISOString().split("T")[0]}`, > ].join(" | "); > > await createQuote({ > // ... other fields > description: description, > }); > ``` > > This makes it easier to match payments in your accounting system and provides context when reviewing transactions. > [accordion] Store transaction IDs in your system > > Always save transaction and quote IDs for audit trails and support: > > ```javascript theme={null} > const quote = await createQuote(quoteData); > > // Save to your database immediately > await db.payments.create({ > quoteId: quote.id, > customerId: customer.id, > amount: quote.totalSendingAmount, > currency: quote.sendingCurrency.code, > status: "pending", > createdAt: new Date(), > }); > > const execution = await executeQuote(quote.id); > > // Update with transaction ID > await db.payments.update( > { quoteId: quote.id }, > { transactionId: execution.transactionId, status: "processing" } > ); > ``` ## Next Steps ![](/images/icons/square-info.svg) Error Handling Handle payment failures and error scenarios [Open](/payouts-and-b2b/payment-flow/error-handling) ![](/images/icons/file-text.svg) List Transactions Query and filter transaction history [Open](/payouts-and-b2b/payment-flow/list-transactions) ![](/images/icons/receipt-check.svg) Reconciliation Match payments with your internal systems [Open](/payouts-and-b2b/payment-flow/reconciliation) # Periodic statements Source: https://agents-docs.lightspark.com/payouts-and-b2b/payment-flow/statements What a periodic statement for a Grid account contains, and how to build one from Grid transaction data Every account held at Lead Bank gets a periodic statement each month, whether or not it had activity. Your platform agreement sets out what the statement contains and who delivers it. Use this page as a reference for a typical statement and how to build one from Grid data. This guide covers when to send a statement, what it contains, how to map Grid data to each statement field, and how to build one once the month settles. Consumer accounts carry a few extra items, called out where they apply. ## Sample statement layout Your statement's visual design is up to you. The layout below shows a consumer account statement with sample data filled in. Check your platform agreement for the fields and disclosures your program requires: September statement Opening balance \$2,450.00USD Closing balance \$3,373.95USD Transactions Amount Total fees for period \$15.00 * See below in case of errors or questions In case of errors or questions about your electronic transfers\* Telephone us at (855) 516-0103 or Write us at 8605 Santa Monica Blvd, PMB 64461, West Hollywood, CA 90069 as soon as you can, if you think your statement or receipt is wrong or if you need more information about a transfer on the statement or receipt. We must hear from you no later than 60 days after we sent you the FIRST statement on which the error or problem appeared.
            We will investigate your complaint and will correct any error promptly. If we take more than 10 business days to do this, we will credit your account for the amount you think is in error, so that you will have the use of the money during the time it takes us to complete our investigation. Report errors within 60 days after we send this statement. This account is held at Lead Bank, the account-holding institution. Lightspark is the program manager and is not a bank. The sample is a consumer statement. It carries three items a commercial statement omits: the asterisk on each line the error-resolution notice covers, the notice itself, and the terminal location on card purchases. Your platform agreement sets out which transaction types carry the marker. ## When to send a statement A statement period is a calendar month in US Central time (`America/Chicago`). Issue a statement for every account each month, with or without activity. Build the statement once the month's card settlement has closed. Until then, [List balance changes](/api-reference/periodic-statements/list-balance-changes-for-a-period) refuses the month with `409 NOT_YET_AVAILABLE`, because its figures could still change. Retry later. Send the statement to the account holder by email, in-app notification, or any channel that lets the account holder **retain** it. Then send Grid a receipt with [Send a receipt for a periodic statement](/api-reference/periodic-statements/send-a-receipt-for-a-periodic-statement), once a month for each account. Grid stores it as the delivery record for that account and month. Your platform agreement sets out who delivers the statement. > [!NOTE] > > Once you issue a statement, **freeze its contents**. A refund or return that posts after the month closes appears on a later statement as its own dated line. ACH returns often land in the following month. ## What a statement contains A statement has two parts: disclosure text, and fields you fill from the account and its transactions. ### Provider disclosures Every statement carries the provider line. A consumer statement also carries the error-resolution notice, which gives the phone number and mailing address. A commercial statement has no notice, so it carries a contact line instead. The sample uses the wording below; your platform agreement provides the disclosure text for your program. | Field | Value | | - | - | | Provider line | “This account is held at Lead Bank, the account-holding institution. Lightspark is the program manager and is not a bank.” | | Error-resolution notice | Consumer statements. The notice text shown in the sample above. | | Phone and address | Consumer statements: inside the error-resolution notice (“Telephone us at … or Write us at …”). Commercial statements: a line reading “Direct inquiries to 8605 Santa Monica Blvd, PMB 64461, West Hollywood, CA 90069 or (855) 516-0103.” | ### Statement fields | Field | Description | Included when | | - | - | - | | Account holder | Name of the account holder | Always | | Account type | Kind of account, for example “Consumer prepaid account” | Always | | Account number | Number that identifies the account, masked to the last four digits | Always | | Statement period | Start and end date of the period | Always | | Issue date | Date the statement is sent | Always | | Opening balance | Balance at the start of the period | Always | | Closing balance | Balance at the end of the period | Always | | Transaction date | Date each transaction posted to the account | Always | | Transaction type | Kind of transfer, for example ACH deposit, debit card purchase, wire transfer out | Always | | Payee or merchant | Counterparty name, or the merchant descriptor for a card purchase | Always | | Terminal location | Merchant city and state | Consumer statements, for card purchases at a merchant terminal, when the card network reports a location | | Transaction amount | Signed amount in the account currency | Always | | Total fees for the period | Sum of all fees charged in the period | Always | ## Mapping Grid data to statement fields You build a statement from four calls: * [Get customer by ID](/api-reference/customers/get-customer-by-id) for the account holder. * [List Customer internal accounts](/api-reference/internal-accounts/list-customer-internal-accounts) for the account. * [List balance changes](/api-reference/periodic-statements/list-balance-changes-for-a-period) for the month's lines and its opening and closing balances. * [Get transaction by ID](/api-reference/transactions/get-transaction-by-id) for each line's type, payee, and merchant. Pass `startDate` as the first instant of the month and `endDate` as the first instant of the next month, both in US Central time. Page with `cursor` while `hasMore` is `true`. The opening and closing balances describe the whole month, so they are the same on every page. All amounts are integers in the smallest unit of their currency (for example, cents), so format them using the currency's `decimals`. | Statement field | Grid source | | - | - | | Account holder | `fullName` on the customer (`customerType: INDIVIDUAL`) or `businessInfo.legalName` (`customerType: BUSINESS`) | | Account type | `type` on the internal account (`INTERNAL_FIAT` for the platform-managed fiat account), shown as a plain-language label | | Account number | `fundingPaymentInstructions[].accountOrWalletInfo.accountNumber` on the internal account, masked to the last four digits | | Statement period | The Central-time month you pass as `startDate` and `endDate` | | Issue date | You supply it: the date the statement is sent | | Opening balance | `openingBalance` from List balance changes | | Closing balance | `closingBalance` from List balance changes | | Transaction date | `effectiveAt` on each balance change: when the balance moved | | Transaction type | `type` (`INCOMING` / `OUTGOING` / `CARD`) and `direction` (`CREDIT` / `DEBIT`) on the change's transaction. On `OUTGOING` transactions, `paymentRail` names the rail | | Payee or merchant | `counterpartyInformation` or `description` on `INCOMING` and `OUTGOING` transactions. `merchant.descriptor` on `CARD` transactions | | Terminal location | `merchant.city` and `merchant.state` on `CARD` transactions, when present | | Transaction amount | `amount` on each balance change, already signed: money out is negative | | Total fees for the period | Sum of `fees` across the month's transactions, counting each transaction once. Subtract the fee of a transaction refunded in the month | **Check before you issue:** `openingBalance + Σ(amount) == closingBalance`, summed over every page. It holds by construction, so a mismatch means a page was missed. > [!NOTE] > > Each balance change is one movement of the balance, so one transaction can produce several lines. An ACH deposit and its later return are two lines. A card purchase that clears in two parts is two lines. A withdrawal's fee can be a line of its own. Several changes can share a `transactionId`, so fetch each transaction once. > > `transactionId` is `null` for an adjustment with no transaction behind it. List it with a generic description, such as "Account adjustment". ## Example: build a monthly statement Once the month has settled, read the account holder and account, list the month's balance changes, look up each line's transaction, render the statement, deliver it, and send Grid the receipt. ```javascript theme={null} // `grid` is your authenticated HTTP client for the Grid base URL. // ERROR_RESOLUTION_NOTICE, maskAccountNumber, and the delivery helpers are yours. // Follow nextCursor until hasMore is false async function* listAll(path, params) { let cursor; do { const query = new URLSearchParams({ ...params, limit: '100' }); if (cursor) query.set('cursor', cursor); const page = await grid.get(`${path}?${query}`); yield* page.data; cursor = page.hasMore ? page.nextCursor : undefined; } while (cursor); } // The first instant of a month in US Central time, as an ISO string with its offset function centralMonthStart(year, month) { const utcMidnight = new Date(Date.UTC(year, month - 1, 1)); const offset = new Intl.DateTimeFormat('en-US', { timeZone: 'America/Chicago', timeZoneName: 'longOffset', }) .formatToParts(utcMidnight) .find((part) => part.type === 'timeZoneName') .value.replace('GMT', ''); return `${String(year).padStart(4, '0')}-${String(month).padStart(2, '0')}-01T00:00:00${offset}`; } function describe(transaction) { if (!transaction) return { type: 'ADJUSTMENT', payee: 'Account adjustment' }; if (transaction.type === 'CARD') { const { descriptor, city, state } = transaction.merchant; return { type: 'CARD', payee: descriptor, terminalLocation: city && state ? `${city}, ${state}` : undefined, }; } return { type: transaction.type, rail: transaction.paymentRail, payee: transaction.description, }; } async function buildStatement(customerId, accountId, year, month) { const customer = await grid.get(`/customers/${customerId}`); const commercial = customer.customerType === 'BUSINESS'; let account; for await (const candidate of listAll('/customers/internal-accounts', { customerId })) { if (candidate.id === accountId) { account = candidate; break; } } if (!account) throw new Error(`Account ${accountId} does not belong to customer ${customerId}`); const startDate = centralMonthStart(year, month); const endDate = month === 12 ? centralMonthStart(year + 1, 1) : centralMonthStart(year, month + 1); // A 409 NOT_YET_AVAILABLE means the month has not settled yet: retry later const path = `/internal-accounts/${accountId}/balance-changes`; const first = await grid.get(`${path}?${new URLSearchParams({ startDate, endDate, limit: '1' })}`); const { openingBalance, closingBalance } = first; const changes = []; for await (const change of listAll(path, { startDate, endDate })) changes.push(change); const movement = changes.reduce((sum, change) => sum + change.amount.amount, 0); if (openingBalance.amount + movement !== closingBalance.amount) { throw new Error('Statement does not reconcile: opening balance plus lines is not the closing balance'); } // Several changes can share one transaction: fetch each once const transactions = new Map(); for (const { transactionId } of changes) { if (transactionId && !transactions.has(transactionId)) { transactions.set(transactionId, await grid.get(`/transactions/${transactionId}`)); } } const lines = []; for (const change of changes) { const detail = describe(transactions.get(change.transactionId)); if (commercial) delete detail.terminalLocation; lines.push({ date: change.effectiveAt, ...detail, amount: change.amount.amount }); } // Count each transaction's fee once; a refund in the month gives it back let totalFees = 0; for (const [transactionId, transaction] of transactions) { if (!transaction.fees || transaction.source?.accountId !== accountId) continue; const own = changes.filter((change) => change.transactionId === transactionId); if (own.some((change) => change.amount.amount < 0)) totalFees += transaction.fees; if (own.some((change) => change.amount.amount > 0)) totalFees -= transaction.fees; } const { currency } = closingBalance; const format = (amount) => (amount / 10 ** currency.decimals).toFixed(currency.decimals); const statement = { // Disclosures: a consumer statement carries the notice, a commercial statement the contact line providerLine: 'This account is held at Lead Bank, the account-holding institution. ' + 'Lightspark is the program manager and is not a bank.', ...(commercial ? { contactLine: 'Direct inquiries to 8605 Santa Monica Blvd, PMB 64461, West Hollywood, CA 90069 or (855) 516-0103.' } : { errorResolutionNotice: ERROR_RESOLUTION_NOTICE }), // the notice includes the phone number and mailing address // Account fields accountHolder: commercial ? customer.businessInfo.legalName : customer.fullName, accountType: account.type, // render as a label, for example "Consumer prepaid account" accountNumber: maskAccountNumber(account.fundingPaymentInstructions), period: { startDate, endDate }, issueDate: new Date().toISOString(), openingBalance: format(openingBalance.amount), closingBalance: format(closingBalance.amount), lines: lines.map((line) => ({ ...line, amount: format(line.amount) })), totalFees: format(totalFees), }; await sendStatementEmail(customer.platformCustomerId, statement); // your delivery channel // The monthly receipt: Grid's record that this account's statement was issued await grid.post(`/internal-accounts/${accountId}/confirm-statement`, { statementMonth: `${year}-${String(month).padStart(2, '0')}`, statementDeliveredAt: statement.issueDate, }); } ``` # Postman Collection Source: https://agents-docs.lightspark.com/payouts-and-b2b/platform-tools/postman-collection > [!NOTE] > > Use our hosted Postman collection to explore endpoints and send test requests quickly. ![](/images/icons/IconPostman.svg) Open Postman Collection Launch the collection in Postman. [Open](https://www.postman.com/lightspark-dev/lightspark-grid/collection/adligoh/grid-api?action=share\&source=copy-link\&creator=49207478) # Sandbox Testing Source: https://agents-docs.lightspark.com/payouts-and-b2b/platform-tools/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` | | **007** | Payout and refund failed | | **010** | Payout rejected by compliance screening — fails with `failureReason: COMPLIANCE_REJECTED` | | **011** | Invalid recipient account details — fails with `failureReason: ACCOUNT_INVALID` | | **012** | Recipient account cannot receive the payout — fails with `failureReason: ACCOUNT_CANNOT_RECEIVE` | | **013** | Transfer limit exceeded — fails with `failureReason: LIMIT_EXCEEDED` | | **Any other** | Successful payment | One suffix fails before any payment starts. Use it to test a corridor that is paused for maintenance: | Suffix | Behavior | | - | - | | **014** | Corridor paused for maintenance — the account lookup and the quote fail with `403 CORRIDOR_UNDER_MAINTENANCE`. Other accounts in the same currency work as normal | 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} # 4. Push to the account: the payout fails with a specific reason # Create external account ending in 011 POST /customers/external-accounts { "accountNumber": "000000011" } # Pay out to it - the transaction fails with # failureReason: ACCOUNT_INVALID POST /quotes GET /transactions/{transactionId} ``` Use suffixes `010` to `013` to test each specific `failureReason`. The table in [Testing Cross-Currency Quotes](#testing-cross-currency-quotes) lists them. ## 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 stamp the exact returned `payloadToSign` string 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 [Encrypt the OTP code](/global-accounts/client-keys#encrypt-the-otp-code-email_otp-only) 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. **1. Create a WebAuthn credential** Generate your own WebAuthn registration challenge and call `navigator.credentials.create()`. **2. Register the passkey** Register the passkey with `POST /auth/credentials`, passing the challenge and attestation returned by the browser. **3. Request a challenge** Reauthenticate with `POST /auth/credentials/{id}/challenge`, passing the compressed P-256 `clientPublicKey` (lowercase hex) whose private key becomes the session signing key. **4. Run the browser assertion** Pass the returned `challenge` into `navigator.credentials.get()` using the returned `credentialId` in `allowCredentials`. **5. Verify the assertion** Verify with `POST /auth/credentials/{id}/verify`, passing the browser assertion and echoing `Request-Id` from the challenge response. The sandbox validates the registered credential ID, WebAuthn challenge, origin/RP binding, user-presence bit, assertion signature, and signature counter. A successful verify response returns the session, just like production. The private key matching the `clientPublicKey` is the session signing key. ```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": "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" }' # 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": "..." } }' ``` ### 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 the lowercase hex SHA-256 digest of `clientPublicKey`, hashed as the exact string sent in the verify request. That string is the compressed public key in lowercase hex: 66 characters starting with `02` or `03`. ```bash theme={null} export PUBLIC_KEY="02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" OIDC_TOKEN=$(node - <<'NODE' const crypto = require("crypto"); const publicKey = process.env.PUBLIC_KEY || "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31"; 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. ### Wallet signature header For `PASSKEY` and `OAUTH` credentials and session refresh, the private key matching the `clientPublicKey` you supplied is the session signing key. For `EMAIL_OTP`, the TEK private key you generated for the encrypted OTP flow is the session signing key. 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. ```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. ## 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. ## 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 # Webhooks Source: https://agents-docs.lightspark.com/payouts-and-b2b/platform-tools/webhooks All webhooks sent by the Grid API include a signature in the `X-Grid-Signature` header, which allows you to verify the authenticity of the webhook. This is critical for security, as it ensures that only legitimate webhooks from Grid are processed by your system. ## Signature Verification Process 1. **Obtain your Grid public key** * This is available in your [dashboard](https://app.lightspark.com) under Developers webhook settings. * The key is in PEM format and can be used with standard cryptographic libraries 2. **Verify incoming webhooks** * Extract the signature from the `X-Grid-Signature` header * Decode the base64 signature * Create a SHA-256 hash of the entire request body * Verify the signature using the Grid webhook public key and the hash * Only process the webhook if the signature verification succeeds ## Verification Examples ### Node.js Example ```javascript theme={null} const crypto = require('crypto'); const express = require('express'); const app = express(); // Your Grid public key provided during integration const GRID_WEBHOOK_PUBLIC_KEY = `-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE... -----END PUBLIC KEY-----`; app.post('/webhooks/uma', (req, res) => { const signatureHeader = req.header('X-Grid-Signature'); if (!signatureHeader) { return res.status(401).json({ error: 'Signature missing' }); } try { let signature: Buffer; try { // Parse the signature as JSON. It's in the format {"v": "1", "s": "base64_signature"} const signatureObj = JSON.parse(signatureHeader); if (signatureObj.v && signatureObj.s) { // The signature is in the 's' field signature = Buffer.from(signatureObj.s, "base64"); } else { throw new Error("Invalid JSON signature format"); } } catch { // If JSON parsing fails, treat as direct base64 signature = Buffer.from(signatureHeader, "base64"); } // Create verifier with the public key and correct algorithm const verifier = crypto.createVerify("SHA256"); const payload = await request.text(); verifier.update(payload); verifier.end(); // Verify the signature using the webhook public key const isValid = verifier.verify( { key: GRID_WEBHOOK_PUBLIC_KEY, format: "pem", type: "spki", }, signature, ); if (!isValid) { return res.status(401).json({ error: 'Invalid signature' }); } // Webhook is verified, process it based on type const webhookData = req.body; if (webhookData.type.startsWith('INCOMING_PAYMENT.')) { // Process incoming payment webhook // ... } else if (webhookData.type.startsWith('OUTGOING_PAYMENT.')) { // Process outgoing payment webhook // ... } // Acknowledge receipt of the webhook return res.status(200).json({ received: true }); } catch (error) { console.error('Signature verification error:', error); return res.status(401).json({ error: 'Signature verification failed' }); } }); app.listen(3000, () => { console.log('Webhook server listening on port 3000'); }); ``` ### Python Example ```python theme={null} from cryptography.hazmat.primitives import serialization, hashes from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature from flask import Flask, request, jsonify import base64 app = Flask(__name__) # Your Grid public key provided during integration GRID_PUBLIC_KEY = """-----BEGIN PUBLIC KEY----- MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE... -----END PUBLIC KEY-----""" # Load the public key public_key = serialization.load_pem_public_key( GRID_PUBLIC_KEY.encode('utf-8') ) @app.route('/webhooks/uma', methods=['POST']) def handle_webhook(): # Get signature from header signature = request.headers.get('X-Grid-Signature') if not signature: return jsonify({'error': 'Signature missing'}), 401 try: # Get the raw request body request_body = request.get_data() # Create a SHA-256 hash of the request body hash_obj = hashes.Hash(hashes.SHA256()) hash_obj.update(request_body) digest = hash_obj.finalize() # Decode the base64 signature signature_bytes = base64.b64decode(signature) # Verify the signature try: public_key.verify( signature_bytes, request_body, ec.ECDSA(hashes.SHA256()) ) except Exception as e: return jsonify({'error': 'Invalid signature'}), 401 # Webhook is verified, process it based on type webhook_data = request.json if webhook_data['type'].startswith('INCOMING_PAYMENT.'): # Process incoming payment webhook # ... pass elif webhook_data['type'].startswith('OUTGOING_PAYMENT.'): # Process outgoing payment webhook # ... pass # Acknowledge receipt of the webhook return jsonify({'received': True}), 200 except Exception as e: print(f'Signature verification error: {e}') return jsonify({'error': 'Signature verification failed'}), 401 if __name__ == '__main__': app.run(port=3000) ``` ## Testing To test your webhook implementation, you can trigger a test webhook from the Grid dashboard. This will send a test webhook to the endpoint you provided during the integration process. The test webhook will also be sent automatically when you update your platform configuration with a new webhook URL. An example of the test webhook payload is shown below: ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000007", "type": "TEST", "timestamp": "2025-08-15T14:32:00Z", "data": {} } ``` You should verify the signature of the webhook using the Grid public key and the process outlined in the [Signature Verification Process](#signature-verification-process) section and then reply with a 200 OK response to acknowledge receipt of the webhook. ## Security Considerations * **Always verify signatures**: Never process webhooks without verifying their signatures. * **Use HTTPS**: Ensure your webhook endpoint uses HTTPS to prevent man-in-the-middle attacks. * **Implement idempotency**: Use the `id` field to prevent processing duplicate webhooks. * **Timeout handling**: Implement proper timeout handling and respond to webhooks promptly. ## Retry Policy The Grid API will retry webhooks with the following policy based on the webhook type: | Webhook Type | Retry Policy | Notes | | - | - | - | | `TEST` | No retries | Used for testing webhook configuration | | `OUTGOING_PAYMENT.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `INCOMING_PAYMENT.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on: 409 (duplicate webhook) or PENDING status since it is served as an approval mechanism in-flow | | `BULK_UPLOAD.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `WALLET_OPERATION.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `INVITATION.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CUSTOMER.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `ACCOUNT.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CARD.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CARD_TRANSACTION.*` | Retry with exponential backoff up to 7 days with maximum interval of 30 mins | No retry on 409 (duplicate webhooks) | | `CARD_AUTHORIZATION.REQUEST` | No retries | Real-time decision request, sent only while `cardConfigs.realTimeAuthorization` is set | # Quickstart Source: https://agents-docs.lightspark.com/payouts-and-b2b/quickstart Send your first cross-border payment ![Payouts quickstart hero](https://mintcdn.com/agents-docs/qL8kqlSYbvexR8cg/images/heroes/hero-quickstart-payouts-light.jpg?fit=max\&auto=format\&n=qL8kqlSYbvexR8cg\&q=85\&s=d76018a20523f34c2c55a523c7f53c6d) This quickstart covers an example of sending a prefunded cross-border payout for a business customer on an unregulated platform. ## Understanding Entity Mapping for B2B Payouts In this guide, the entities map as follows: | Entity Type | Who They Are | In This Example | | - | - | - | | **Platform** | Your payouts platform | Your company providing AP automation | | **Customer** | Business sending payments | Your client company (e.g., Acme Corp) | | **External Account** | Vendors receiving payments | Maria Garcia (freelance contractor in Mexico) | **Flow**: Your customer (a business) funds their internal account → uses your platform to send payments → to their vendors' external bank accounts. ## Get API credentials Create Sandbox API credentials in the dashboard, then set environment variables for local use. ```bash theme={null} export GRID_BASE_URL="https://api.lightspark.com/grid/2025-10-13" export GRID_CLIENT_ID="YOUR_SANDBOX_CLIENT_ID" export GRID_CLIENT_SECRET="YOUR_SANDBOX_CLIENT_SECRET" ``` > [!TIP] > > Use Basic Auth in cURL with `-u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET"`. ## Onboard a Customer Onboard a customer using the hosted KYC/KYB link flow. The flow is two steps: create the customer, then generate a hosted KYC link for that customer. ### Create the customer Call `POST /customers` with the customer's `customerType` and any details you already have. ```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 '{ "customerType": "INDIVIDUAL", "platformCustomerId": "019542f5-b3e7-1d02-0000-000000000001", "region": "US", "currencies": ["USD", "USDC"], "email": "jane.doe@example.com", "fullName": "Jane Doe" }' ``` Persist the returned `id` — you'll use it as `customerId` in the next step. ### Generate a KYC link Call `POST /customers/{customerId}/kyc-link` to get a single-use hosted URL. ```bash theme={null} curl -X POST "https://api.lightspark.com/grid/2025-10-13/customers/Customer:019542f5-b3e7-1d02-0000-000000000001/kyc-link" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "redirectUri": "https://yourapp.com/onboarding-complete" }' ``` **Response:** ```json theme={null} { "kycUrl": "https://kyc.lightspark.com/onboard/abc123def456", "expiresAt": "2027-01-15T14:32:00Z", "provider": "SUMSUB", "token": "_act-sbx-jwt-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` > [!TIP] > > The `token` is only present for providers that support direct SDK integration. You can pass it to the provider's SDK to embed verification in your own UI instead of redirecting; both paths update the customer's `kycStatus` identically. ### Redirect Customer Redirect your customer to the returned `kycUrl` where they can complete their identity verification in the hosted interface. > [!WARNING] > > The KYC link is single-use and expires at `expiresAt`. If a customer needs to retry, call the endpoint again to generate a new link. ### Customer Completes Verification The customer completes the identity verification process in the hosted KYC interface, providing required documents and information. > [!NOTE] > > The hosted interface handles document collection, verification checks, and compliance requirements automatically. ### Track the decision Reaching the `redirectUri` only means the customer **finished the hosted flow** — not that they were approved. Wait for the final decision via the `CUSTOMER.KYC_APPROVED` / `CUSTOMER.KYC_REJECTED` webhook (use the `CUSTOMER.KYB_*` siblings for business customers) — recommended — or by polling `GET /customers/{customerId}` and inspecting `kycStatus`. On `APPROVED`, the customer is ready to transact and you can unlock funding. ## Get the Customer's Internal Account Once the customer is created, internal accounts will automatically be created on their behalf. Get their internal account in the desired currency for funding instructions. ```bash theme={null} curl -X GET "https://api.lightspark.com/grid/2025-10-13/internal-account?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001¤cy=USD" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response:** ```json theme={null} { "data": [ { "id": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "balance": { "amount": 0, // USD balance in cents "currency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 } }, "totalBalance": { "amount": 0, "currency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 } }, "fundingPaymentInstructions": [ { "accountOrWalletInfo": { "reference": "FUND-ABC123", "accountType": "USD_ACCOUNT", "accountNumber": "9876543210", "routingNumber": "021000021", "bankName": "JP Morgan Chase" } }, { "accountOrWalletInfo": { "accountType": "SOLANA_WALLET", "assetType": "USDC", "address": "4Nd1m6Qkq7RfKuE5vQ9qP9Tn6H94Ueqb4xXHzsAbd8Wg" } } ], "createdAt": "2025-10-03T12:00:00Z", "updatedAt": "2025-10-03T12:00:00Z" } ], "hasMore": false, "totalCount": 1 } ``` The `fundingPaymentInstructions` provide the bank account details needed to fund this internal account via ACH or wire transfer from the customer's bank. It might also include stablecoin funding details for instant funding of the internal account. ## Fund the Internal Account For this quickstart, we'll fund the account using the `/sandbox/internal-accounts/{accountId}/fund` endpoint to simulate receiving funds. In production, your customer would initiate a transfer from their bank or wallet to the account details provided in the funding instructions. ```bash theme={null} # Sandbox: fund internal account directly curl -X POST "https://api.lightspark.com/grid/2025-10-13/sandbox/internal-accounts/InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965/fund" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "amount": 100000 }' ``` During the funding process, you'll receive transaction status update webhooks. **Webhook Notification:** ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000020", "type": "INCOMING_PAYMENT.COMPLETED", "timestamp": "2025-10-03T14:32:00Z", "data": { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000010", "status": "COMPLETED", "type": "INCOMING", "direction": "CREDIT", "receivedAmount": { "amount": 100000, "currency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 } }, "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "settledAt": "2025-10-03T14:30:00Z", "createdAt": "2025-10-03T14:25:00Z", "description": "Internal account funding" } } ``` The internal account now has a balance of \$1,000.00 (100000 cents). ## Add the beneficiary as an External Account Now add the beneficiary bank account where you want to send the funds. In this example, we'll add a Mexican CLABE account as the external 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:019542f5-b3e7-1d02-0000-000000000001", "currency": "MXN", "platformAccountId": "maria_garcia_account", "accountInfo": { "accountType": "MXN_ACCOUNT", "clabeNumber": "123456789012345678", "bankName": "BBVA Mexico", "beneficiary": { "beneficiaryType": "INDIVIDUAL", "fullName": "Maria Garcia", "birthDate": "1990-01-01", "nationality": "MX", "address": { "line1": "Av. Reforma 123", "city": "Ciudad de México", "state": "CDMX", "postalCode": "06600", "country": "MX" } } } }' ``` **Response:** ```json theme={null} { "id": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "status": "ACTIVE", "currency": "MXN", "platformAccountId": "maria_garcia_account", "accountInfo": { "accountType": "MXN_ACCOUNT", "clabeNumber": "123456789012345678", "bankName": "BBVA Mexico", "beneficiary": { "beneficiaryType": "INDIVIDUAL", "fullName": "Maria Garcia", "birthDate": "1990-01-01", "nationality": "MX", "address": { "line1": "Av. Reforma 123", "city": "Ciudad de México", "state": "CDMX", "postalCode": "06600", "country": "MX" } } } } ``` ## Create a quote Create a quote to lock in the exchange rate, fees, and get the transfer details. ```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:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" # USD internal account }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 50000, "description": "Payment to Maria Garcia for services" }' ``` > [!NOTE] > > **Amount Locking**: You can lock either the sending amount (`SENDING`) or receiving amount (`RECEIVING`). > In this example, we're locking the sending amount to exactly \$500.00 USD (50000 cents). Alternatively, > you can lock the receiving amount to ensure that the receiver receives exactly some amount of the > destination currency. **Response:** ```json theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000006", "status": "PENDING", "createdAt": "2025-10-03T15:00:00Z", "expiresAt": "2025-10-03T15:05:00Z", "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "sendingCurrency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 }, "receivingCurrency": { "code": "MXN", "name": "Mexican Peso", "symbol": "$", "decimals": 2 }, "totalSendingAmount": 50000, "totalReceivingAmount": 861250, "exchangeRate": 17.25, "feesIncluded": 250, "platformFeesIncluded": 0, "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000015" } ``` The quote shows: * **Sending**: \$500.00 USD (including \$2.50 fee) * **Receiving**: \$8,612.50 MXN * **Exchange rate**: 17.25 MXN per USD * **Quote expires**: In 5 minutes > [!WARNING] > > Quotes typically expire in 1-5 minutes. Make sure to execute the quote before the `expiresAt` timestamp, or you'll need to create a new quote. ## Execute the quote Execute the quote to initiate the transfer from the internal account to the external bank account. ```bash theme={null} curl -X POST "https://api.lightspark.com/grid/2025-10-13/quotes/Quote:019542f5-b3e7-1d02-0000-000000000006/execute" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response:** ```json theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000006", "status": "PROCESSING", "createdAt": "2025-10-03T15:00:00Z", "expiresAt": "2025-10-03T15:05:00Z", "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "sendingCurrency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 }, "receivingCurrency": { "code": "MXN", "name": "Mexican Peso", "symbol": "$", "decimals": 2 }, "totalSendingAmount": 50000, "totalReceivingAmount": 861250, "exchangeRate": 17.25, "feesIncluded": 250, "platformFeesIncluded": 0, "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000015" } ``` The quote status changes to `PROCESSING` and the transfer is initiated. You'll receive a webhook when the transfer completes: **Completion Webhook:** ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000025", "type": "OUTGOING_PAYMENT.COMPLETED", "timestamp": "2025-10-03T15:03:00Z", "data": { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000015", "status": "COMPLETED", "type": "OUTGOING", "direction": "DEBIT", "sentAmount": { "amount": 50000, "currency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 } }, "receivedAmount": { "amount": 861250, "currency": { "code": "MXN", "name": "Mexican Peso", "symbol": "$", "decimals": 2 } }, "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "settledAt": "2025-10-03T15:02:30Z", "createdAt": "2025-10-03T15:00:00Z", "description": "Payment to Maria Garcia for services", "exchangeRate": 17.25, "quoteId": "Quote:019542f5-b3e7-1d02-0000-000000000006" } } ``` Congrats, you've sent a real time cross-border payout to a Mexican bank account! # Core Concepts Source: https://agents-docs.lightspark.com/payouts-and-b2b/terminology Core concepts and terminology for the Grid API There are several key entities in the Grid API: **Platform**, **Customers**, **Internal Accounts**, **External Accounts**, **Quotes**, **Transactions**, and **UMA Addresses**. ![Entity relationships diagram showing platform, customers, internal accounts, external accounts, transactions, and UMA addresses](https://mintcdn.com/agents-docs/qL8kqlSYbvexR8cg/images/entities-light.svg?fit=max\&auto=format\&n=qL8kqlSYbvexR8cg\&q=85\&s=e5d51454ebea529d7d9219a140ab7e1e) ## Businesses, People, and Accounts ### Platform Your **platform** is you! It's the top-level entity that integrates with the Grid API. The platform: * Has its own configuration (webhook endpoint, supported currencies, API tokens, etc.) * A platform can have many customers both business and individual * Manages multiple customers and their accounts * Can hold platform-owned internal accounts for settlement and liquidity management * Acts as the integration point between your application and the open Money Grid ### Customers **Customers** are your end users who send and receive payments through your platform. Each customer: * Can be an individual or business entity * Has a KYC/KYB status that determines their ability to transact. If you are a regulated financial institution, this will typically be `APPROVED` since you do the KYC/KYB yourself. * Is identified by both a system-generated ID and optionally your platform-specific customer ID * May have associated internal accounts and external accounts * May have a unique **UMA address** (e.g., `$john.doe@yourdomain.com`). If you don't assign an UMA address when creating a customer, they will be assigned a system-generated one. ### Internal Accounts **Internal accounts** are Grid-managed accounts that hold balances in specific currencies. They can belong to either: * **Platform internal accounts** - Owned by the platform for settlement, liquidity, and float management * **Customer internal accounts** - Associated with specific customers for holding funds Internal accounts: * Have balances in a single currency (USD, EUR, MXN, etc.) * Can be funded via bank transfers or crypto deposits using payment instructions * Are used as sources or destinations for transactions instantly 24/7/365 * Track available balance for sending payments or receiving funds > [!NOTE] > > **Global Accounts** are a special kind of internal account, powered by a self-custody embedded [Spark](https://spark.money) wallet that Grid provisions for your customers to hold a stablecoin balance. They behave like any other internal account on the way in, but every outbound transfer must be authorized by a customer signature. See [Global Accounts](/payouts-and-b2b/global-accounts/overview) for the full overview. ### External Accounts **External accounts** are traditional bank accounts, crypto wallets, or other payment instruments connected to customers for on-ramping or off-ramping funds. Each external account: * Are associated with a specific customer or the platform * Represents a real-world bank account (with routing number, account number, IBAN, etc.), wallet, or payment instrument * Has an associated beneficiary (individual or business) who receives payments from the customer or platform * Has a status indicating screening status (ACTIVE, PENDING, INACTIVE, etc.) * Can be used as a destination for quote-based transfers or same currency transfers like withdrawals * For pullable sources like debit cards or ACH pulls, an external account can be used as a source for transfers-in to fund internal accounts or to fund cross-border transfers via quotes. ## Entity Examples by Use Case Understanding how entities map to your specific use case helps clarify your integration architecture. Here are common examples: ### B2B Payouts Platform (e.g., Bill.com, Routable) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The payouts platform itself | Your company providing AP automation | | **Customer** | Businesses sending payments to vendors | Acme Corp (your client company) | | **External Account** | Vendors/suppliers receiving payments | Office supply vendor, freelance contractor | **Flow**: Acme Corp (customer) uses your platform to pay their vendor invoices → funds move from Acme's internal account → to vendor's external bank account ### Direct Rewards Platform (Platform-Funded Model) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The app paying rewards directly to users | Your cashback app | | **Customer** | (Not used in this model) | N/A | | **External Account** | End users' crypto wallets receiving rewards | Sarah's self-custody Bitcoin wallet | **Flow**: Your platform sends micro-payouts directly from platform internal accounts → to users' external crypto wallets at scale. Common for cashback apps where the platform earns affiliate commissions and shares them with users. ### White-Label Rewards Platform (Customer-Funded Model) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The rewards infrastructure provider | Your white-label rewards API | | **Customer** | Brands or merchants running reward campaigns | Nike, Starbucks | | **External Account** | End users' crypto wallets receiving rewards | Sarah's self-custody Bitcoin wallet | **Flow**: Nike (customer) funds their internal account → your platform sends rewards on their behalf → to users' external crypto wallets. Common for brand loyalty programs where merchants manage their own reward budgets. ### Remittance/P2P App (e.g., Wise, Remitly) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The remittance service | Your money transfer app | | **Customer** | Both sender and recipient of funds | Maria (sender in US), Juan (recipient in Mexico) | | **External Account** | Bank accounts for funding/receiving | Maria's US bank (funding), Juan's Mexican bank (receiving funds) | **Flow**: Maria (customer) funds transfer from her external account → to Juan (also a customer) → who receives funds in his external bank account. Alternatively, Maria could send to Juan's UMA address directly. ## Transactions and Addressing Entities ### Quotes **Quotes** provide locked-in exchange rates and payment instructions for transfers. A quote: * Specifies a source (internal account, customer ID, or the platform itself) and destination (internal/external account or UMA address) * Locks an exchange rate for a short period (typically 1-5 minutes) or can be immediately executed with the `immediatelyExecute` flag * Calculates total fees and amounts for currency conversion * Provides payment instructions for funding the transfer if needed, or can be funded via an internal account balance. * Must be executed before it expires * Creates a transaction when executed ### Transactions **Transactions** represent completed or in-progress payment transfers. Each transaction: * Has a type (INCOMING or OUTGOING), keyed on whether the destination is an internal account * Has a status (PENDING, COMPLETED, FAILED, etc.) * References a customer (sender for outgoing, recipient for incoming) or a platform internal account * Specifies source and destination (accounts or UMA addresses) * Includes amounts, currencies, and settlement information * May include counterparty information for compliance purposes if required by your platform configuration Transactions are created when a quote is executed, incoming or outgoing. This covers same-currency and cross-currency transfers alike. ### UMA Addresses (optional) **UMA addresses** are human-readable payment identifiers that follow the format `$username@domain.com`. They: * Uniquely identify entities on the Grid network * Enable sending and receiving payments across different platforms without knowing the recipient's underlying account details or personal information * Support currency negotiation and cross-border transfers * Work similar to email addresses but for payments * Are an optional UX improvement for some use cases. Use of UMA addresses is not required in order to use the Grid API. # Building with AI Source: https://agents-docs.lightspark.com/platform-overview/building-with-ai Use AI coding assistants like Claude Code, Cursor, and Codex to explore, build, and debug with the Grid API ![Building with AI hero](https://mintcdn.com/agents-docs/qL8kqlSYbvexR8cg/images/heroes/hero-building-with-ai-light.svg?fit=max\&auto=format\&n=qL8kqlSYbvexR8cg\&q=85\&s=d3aea60e4880cd0ac729b10c18d6ed82) Grid's documentation, OpenAPI spec, and CLI are designed to work with AI coding assistants like Claude Code, Cursor, and Codex. Whether you're exploring the API for the first time or building a production integration, AI tools can help you move faster. ## What AI assistants can do with Grid Explore the API Ask questions about endpoints, request/response schemas, supported currencies, and payment rails Execute API calls Use the Grid Skill to create customers, manage accounts, get quotes, and send payments directly from your AI assistant Guide workflows Get step-by-step guidance for multi-step flows like international payouts, on/off-ramps, and KYC onboarding Debug issues Troubleshoot error codes, validate account details, and diagnose failed transactions ## AI-accessible documentation These Grid docs are automatically available to LLMs and AI tools in machine-readable formats — no configuration needed. ### llms.txt Grid docs generate [llms.txt](https://llmstxt.org/) files that give AI tools a structured index of all documentation: * [`/llms.txt`](/llms.txt) — Concise index of all pages with titles and descriptions * [`/llms-full.txt`](/llms-full.txt) — Complete documentation content in a single file These are generated automatically and always up to date. Use `llms-full.txt` when you want to give an AI assistant full context about the Grid API in one shot. ### Markdown export Each page in the Grid docs is automatically available as a Markdown file simply by adding `.md` to the end of the URL. For example, the [Building with AI](/platform-overview/building-with-ai) page is available as [`/platform-overview/building-with-ai.md`](/platform-overview/building-with-ai.md). > [!TIP] > > You can also copy any page's content as Markdown with the keyboard shortcut Command + C (Ctrl + C on Windows) and paste it directly into ChatGPT, Claude, or any AI assistant for context-aware help with your specific question. ## Install the Grid API agent skill The Grid API skill gives [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or another Skill-compatible agent full access to the Grid API via a built-in CLI. Install it with: ```bash theme={null} npx skills add lightsparkdev/grid-api ``` Make sure to install it for whichever agent you're using. For example, if you're using Claude Code, you'll need to explicitly select Claude Code in the agent installation selection screen. Once installed, you can start asking questions immediately. To execute API operations, you'll need to configure your credentials. ### Configure your credentials To set your grid credentials, simply ask the agent to help you configure them: ``` Help me configure my Grid API credentials. ``` It'll prompt you for your API Token ID and Client Secret, validate them, and save to `~/.grid-credentials` for future use. > [!TIP] > > Start in the sandbox environment to experiment safely. The Skill is great at generating fake account data to help you test different flows. ## MCP server Grid provides a [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server that lets AI agents search Grid's API documentation **and** execute Grid API operations directly. Your agentic workflows get full context of the Grid API during implementation and can run quotes, transfers, customer onboarding, and other operations end-to-end. ```text theme={null} https://mcp.grid.lightspark.com/ ``` The MCP server exposes two tools: * **`search_docs`** — Search Grid's API documentation for endpoints, parameters, and usage examples * **`execute`** — Interact with the Grid API to create customers, manage accounts, generate and execute quotes, send transfers, look up receivers, and call any other Grid API operation > [!WARNING] > > Because `execute` calls the live Grid API with your credentials, scope your API token carefully. Use a **read-only token in sandbox** while experimenting; only issue a write-capable or production-scoped token when you understand the operations the agent will run. ### Create an API token The MCP server requires Grid API credentials. In the [Grid dashboard](https://app.lightspark.com), create a scoped API token — a **read-only sandbox token** is recommended for first-time setup — and copy the client ID and client secret. You'll plug these into the `x-grid-client-id` and `x-grid-client-secret` headers in the configurations below. ### Configure your client > [tab] Claude Code > > In Claude Code, ask Claude to update your `.claude.json` config with: > > ```json theme={null} > { > "mcpServers": { > "grid_mcp": { > "type": "http", > "url": "https://mcp.grid.lightspark.com/", > "headers": { > "x-grid-client-id": "{client id}", > "x-grid-client-secret": "{client secret}" > } > } > } > } > ``` > [tab] Claude desktop app > > In the Claude desktop app, open **Settings → Developer**, edit `claude_desktop_config.json`, and add: > > ```json theme={null} > { > "mcpServers": { > "grid_mcp": { > "command": "npx", > "args": [ > "-y", > "mcp-remote", > "https://mcp.grid.lightspark.com/", > "--header", > "x-grid-client-id: {client id}", > "--header", > "x-grid-client-secret: {client secret}" > ] > } > } > } > ``` > > The desktop app uses [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) to bridge to a remote HTTP MCP server. > [tab] Cursor > > In Cursor, add the server to `~/.cursor/mcp.json` (global) or `.cursor/mcp.json` in your project root: > > ```json theme={null} > { > "mcpServers": { > "grid_mcp": { > "url": "https://mcp.grid.lightspark.com/", > "headers": { > "x-grid-client-id": "{client id}", > "x-grid-client-secret": "{client secret}" > } > } > } > } > ``` > [tab] ChatGPT and others > > Any client that supports MCP's streamable HTTP transport (ChatGPT, Windsurf, Zed, Cline, and others) can connect using the same URL and headers: > > * **URL:** `https://mcp.grid.lightspark.com/` > * **Headers:** `x-grid-client-id: {client id}` and `x-grid-client-secret: {client secret}` > > For clients that don't yet support HTTP-transport MCP servers natively, use [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) as a bridge — see the Claude desktop app tab for an example. ## Example prompts Try these prompts in Claude Code or paste them into your AI assistant of choice: ### Getting started ``` What currencies does Grid support? Show me the coverage by country. ``` ``` Walk me through the steps to send a payout from USD to MXN via CLABE. ``` ### Payouts ``` Create an external account for a bank in Mexico using CLABE, then create a quote to send $500 USD and show me the exchange rate before executing. ``` ``` Send $100 to $alice@example.com via UMA. Show me the exchange rate first. ``` ### On/off-ramps ``` Help me set up a fiat-to-crypto on-ramp. I want to convert USD to BTC using a Spark wallet. ``` ``` I need to off-ramp USDC to a US bank account. Walk me through the full flow. ``` ### Account management ``` List all my customers and their KYC status in a table. ``` ``` Create a new individual customer and generate a KYC onboarding link. ``` ### Debugging ``` I'm getting QUOTE_EXPIRED errors. What's happening and how do I fix it? ``` ``` My external account creation is failing for a Nigerian bank account. What fields am I missing? ``` ## Tips for best results 1. **Be specific about your use case** — "Send a payout to Brazil via PIX" gets better results than "help me send money" 2. **Start with the sandbox** — Ask the AI to use sandbox mode so you can experiment without real funds 3. **Give context** — Paste the relevant docs page or point the AI to `/llms-full.txt` for full API context 4. **Iterate on errors** — If an API call fails, paste the error and ask the AI to diagnose it # Capabilities Source: https://agents-docs.lightspark.com/platform-overview/capabilities ![Capabilities hero](https://mintcdn.com/agents-docs/qL8kqlSYbvexR8cg/images/heroes/hero-capabilities-light.svg?fit=max\&auto=format\&n=qL8kqlSYbvexR8cg\&q=85\&s=9e14847076f223e8c7be6717846a112a) Lightspark exposes low-level primitives that you combine to build any payment flow. Fiat, crypto, or both. Send Push funds to bank accounts in 65+ countries, crypto wallets, or UMA addresses. You choose the destination, Grid handles delivery. [View coverage](/platform-overview/core-concepts/currencies-and-rails) Receive Accept incoming payments via bank transfer, crypto deposit, or UMA. Webhooks notify you in real-time. [Receiving payments](/global-p2p/sending-receiving-payments/receiving-payments) Convert Exchange between fiat currencies or convert fiat to crypto. Lock in rates before execution. [How quotes work](/platform-overview/core-concepts/quote-system) Hold Maintain balances in USD, EUR, BRL, BTC, USDC, and more. Fund and manage accounts via API. [Account model](/platform-overview/core-concepts/account-model) Ramp On-ramp from bank accounts to BTC or stablecoins. Off-ramp crypto to local bank rails instantly. [Explore ramps](/ramps) Bill Generate payment requests and collect funds. Track payment status through completion. Coming soon Program Automate flows with webhooks, approval logic, and idempotent operations. Build any payment experience. [API reference](/api-reference) Identify KYC/KYB verification—use Grid's hosted flows or bring your own compliance. [Onboarding guide](/payouts-and-b2b/onboarding/configuring-customers) Issue Issue debit cards backed by internal accounts, or issue stablecoins and manage minting and burning through the API. [Card issuing](/cards/card-management/issuing-cards) [Stablecoin issuance](/api-reference/stablecoins/request-grid-enablement-for-a-stablecoin) # Configuration Source: https://agents-docs.lightspark.com/platform-overview/configuration Configure your Grid integration Learn how to configure Grid for your specific use case. ## Platform Configuration Before you can start using Grid, you'll need to configure your platform settings. This includes: * Setting up your API credentials * Configuring webhooks for real-time updates * Defining your supported currencies and payment rails * Setting up compliance and KYC requirements ## Environment Setup Grid provides two environments: ![](/images/icons/sandbox.svg) Sandbox Test your integration with simulated payments [Open](/api-reference/environments) ![](/images/icons/lightning.svg) Production Process real payments with live credentials [Open](/api-reference/environments) ## Next Steps ![](/images/icons/key2.svg) Authentication Learn how to authenticate API requests [Open](/api-reference/authentication) ![](/images/icons/bell.svg) Webhooks Set up real-time event notifications [Open](/payouts-and-b2b/platform-tools/webhooks) # Account Model Source: https://agents-docs.lightspark.com/platform-overview/core-concepts/account-model Internal accounts, external accounts, and how they work together Grid uses two types of accounts: **internal accounts** (Grid-managed balances) and **external accounts** (connected bank accounts and wallets). Understanding when to use each type is key to building efficient payment flows. ## Internal Accounts Internal accounts are Lightspark managed accounts that hold funds within the Grid platform. They allow you to receive deposits and send payments to external bank accounts or other payment destinations. They are useful for holding funds on behalf of the platform or customers which will be used for instant, 24/7 quotes and transfers out of the system. Internal accounts are created for both: * **Platform-level accounts**: Hold pooled funds for your platform operations (rewards distribution, reconciliation, etc.) * **Customer accounts**: Hold individual customer funds for their transactions > [!NOTE] > > Internal accounts are automatically created when you onboard a customer, based > on your platform's currency configuration. Platform-level internal accounts > are created when you configure your platform with supported currencies. > [!WARNING] > > On unregulated platforms, a customer's internal accounts aren't provisioned > until you record their End User Terms acceptance in `agreementConsents`, and > customer-scoped transactions are rejected with `403 > END_USER_TERMS_NOT_ACCEPTED` until then. See > [Disclosures](/payouts-and-b2b/onboarding/disclosures). ## How internal accounts work Internal accounts act as an intermediary holding account in the payment flow: 1. **Deposit funds**: You or your customers deposit money into internal accounts using bank transfers (ACH, wire, PIX, etc.) or crypto transfers 2. **Hold balance**: Funds are held securely in the internal account until needed 3. **Send payments**: You initiate transfers from internal accounts to external destinations Each internal account: * Is denominated in a single currency (USD, EUR, etc.) * Has a unique balance that you can query at any time * Includes unique payment instructions for depositing funds * Supports multiple funding methods depending on the currency ## Retrieving internal accounts ### List customer internal accounts To retrieve all internal accounts for a specific customer, use the customer ID to filter the results: ```bash Request internal accounts for a customer theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [accordion] Response > > ```json theme={null} > { > "data": [ > { > "id": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "type": "INTERNAL_FIAT", > "status": "ACTIVE", > "balance": { > "amount": 50000, > "currency": { > "code": "USD", > "name": "United States Dollar", > "symbol": "$", > "decimals": 2 > } > }, > "totalBalance": { > "amount": 50000, > "currency": { > "code": "USD", > "name": "United States Dollar", > "symbol": "$", > "decimals": 2 > } > }, > "fundingPaymentInstructions": [ > { > "accountOrWalletInfo": { > "reference": "FUND-ABC123", > "accountType": "USD_ACCOUNT", > "accountNumber": "9876543210", > "routingNumber": "021000021", > "bankName": "JP Morgan Chase", > "bankAddress": "270 Park Avenue, New York, NY 10017" > } > }, > { > "accountOrWalletInfo": { > "accountType": "SOLANA_WALLET", > "assetType": "USDC", > "address": "4Nd1m6Qkq7RfKuE5vQ9qP9Tn6H94Ueqb4xXHzsAbd8Wg" > } > } > ], > "createdAt": "2025-10-03T12:00:00Z", > "updatedAt": "2025-10-03T14:30:00Z" > } > ], > "hasMore": false, > "totalCount": 1 > } > ``` ### Filter by currency You can filter internal accounts by currency to find accounts for specific denominations: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001¤cy=USD' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ### List platform internal accounts To retrieve platform-level internal accounts (not tied to individual customers), use the platform internal accounts endpoint: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/platform/internal-accounts' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [!NOTE] > > Platform internal accounts are useful for managing pooled funds, distributing > rewards, or handling platform-level operations. ## Understanding funding payment instructions Each internal account includes `fundingPaymentInstructions` that tell your customers how to deposit funds. The structure varies by payment rail and currency: > [accordion] US ACH or wire transfers > > For USD accounts, instructions include routing and account numbers: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "USD_ACCOUNT", > "reference": "FUND-ABC123", > "accountNumber": "9876543210", > "routingNumber": "021000021", > "bankName": "JP Morgan Chase", > "bankAddress": "270 Park Avenue, New York, NY 10017" > } > } > ``` > > > [!TIP] > > > > Each internal account has its own account number, and deposits are > > credited by account number. The payer doesn't need to include the > > `reference`. A deposit sent without it is still credited to this account. > > The account is held in the account owner's own legal name: the customer's > `fullName` for an individual, or `businessInfo.legalName` for a business. > A platform internal account is held in your platform's legal name. Show > that name to payers as the beneficiary name, and ask them to copy it > exactly on wires. > [accordion] EUR account funding (SEPA) > > A `EUR_ACCOUNT` returns an IBAN, and deposits reach it over SEPA: > > ```json theme={null} > { > "instructionsNotes": "Include reference in SEPA transfer description", > "accountOrWalletInfo": { > "accountType": "EUR_ACCOUNT", > "reference": "FUND-EUR789", > "iban": "DE89370400440532013000", > "swiftCode": "DEUTDEFF", > "bankName": "Deutsche Bank" > } > } > ``` > [accordion] Spark Stablecoin Funding > > For stablecoin accounts, using a Spark wallet as the funding source: > > ```json theme={null} > { > "invoice": "lnbc15u1p3xnhl2pp5jptserfk3zk4qy42tlucycrfwxhydvlemu9pqr93tuzlv9cc7g3sdqsvfhkcap3xyhx7un8cqzpgxqzjcsp5f8c52y2stc300gl6s4xswtjpc37hrnnr3c9wvtgjfuvqmpm35evq9qyyssqy4lgd8tj637qcjp05rdpxxykjenthxftej7a2zzmwrmrl70fyj9hvj0rewhzj7jfyuwkwcg9g2jpwtk3wkjtwnkdks84hsnu8xps5vsq4gj5hs", > "instructionsNotes": "Use the invoice when making Spark payment", > "accountOrWalletInfo": { > "accountType": "SPARK_WALLET", > "assetType": "USDB", > "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu" > } > } > ``` > [accordion] Solana Stablecoin Funding > > For Solana wallet accounts, using a Solana wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "SOLANA_WALLET", > "assetType": "USDC", > "address": "4Nd1m6Qkq7RfKuE5vQ9qP9Tn6H94Ueqb4xXHzsAbd8Wg" > } > } > ``` > [accordion] Ethereum Stablecoin Funding > > For Ethereum L1 wallet accounts (USDC or USDT), using an Ethereum wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "ETHEREUM_WALLET", > "assetType": "USDT", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` > [accordion] Tron Stablecoin Funding > > For Tron wallet accounts, using a Tron wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "TRON_WALLET", > "assetType": "USDT", > "address": "TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL" > } > } > ``` > [accordion] Plasma Stablecoin Funding > > For Plasma wallet accounts, using a Plasma wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "PLASMA_WALLET", > "assetType": "USDT", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` > [accordion] Arbitrum Stablecoin Funding > > For Arbitrum wallet accounts, using an Arbitrum wallet as the funding source (USDC or USDT): > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "ARBITRUM_WALLET", > "assetType": "USDC", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` > [accordion] Polygon Stablecoin Funding > > For Polygon wallet accounts, using a Polygon wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "POLYGON_WALLET", > "assetType": "USDC", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` > [accordion] Base Stablecoin Funding > > For Base wallet accounts, using a Base wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "BASE_WALLET", > "assetType": "USDC", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` ## Checking account balances The internal account balance reflects all deposits and withdrawals. The balance includes: * **amount**: The balance amount in the smallest currency unit (cents for USD, centavos for MXN/BRL, etc.) * **currency**: Full currency details including code, name, symbol, and decimal places ### Example balance check ```bash Fetch the balance of an internal account theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001¤cy=USD' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [accordion] Response > > ```json theme={null} > { > "data": [ > { > "id": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "type": "INTERNAL_FIAT", > "status": "ACTIVE", > "balance": { > "amount": 50000, > "currency": { > "code": "USD", > "name": "United States Dollar", > "symbol": "$", > "decimals": 2 > } > }, > "totalBalance": { > "amount": 50000, > "currency": { > "code": "USD", > "name": "United States Dollar", > "symbol": "$", > "decimals": 2 > } > } > } > ], > "hasMore": false, > "totalCount": 1 > } > ``` > [!TIP] > > Always check the `decimals` field in the currency object to correctly convert > between display amounts and API amounts. For example, USD has 2 decimals, so > an amount of 50000 represents \$500.00. ## Displaying funding instructions to customers When customers need to deposit funds themselves, display the funding payment instructions in your application: **1. Retrieve the internal account** Fetch the customer's internal account for their desired currency using the API. **2. Extract payment instructions** Parse the `fundingPaymentInstructions` array and select the appropriate instructions based on your customer's preferred payment method. ```javascript theme={null} const instructions = account.fundingPaymentInstructions[0]; const bankInfo = instructions.accountOrWalletInfo; ``` **3. Display instructions clearly** Show the payment details prominently in your UI: * Account holder name * Bank name and routing information (account/routing number, CLABE, PIX key, etc.) * Reference code (if provided) * Any additional notes from `instructionsNotes` > [!TIP] > > The unique banking details in each internal account automatically route > deposits to the correct destination. **4. Monitor for deposits** Set up webhook listeners to receive notifications when deposits are credited to the internal account. The account balance will update automatically. > [!NOTE] > > You'll receive `INTERNAL_ACCOUNT.BALANCE_UPDATED` webhook events when the internal account balance changes. ## Best practices > [accordion] Display complete funding instructions > > Ensure your customers have all the information needed to make deposits. Consider implementing: > > * Clear display of all banking details from `fundingPaymentInstructions` > * Copy-to-clipboard functionality for account numbers and reference codes > * Email/SMS confirmations with complete deposit instructions > [accordion] Monitor account balances proactively > > Set up monitoring to alert customers when their balance is low: > > ```javascript theme={null} > if (account.balance.amount < minimumThreshold) { > await notifyCustomer({ > type: 'LOW_BALANCE', > account: account.id, > instructions: account.fundingPaymentInstructions > }); > } > ``` > [accordion] Handle multiple currencies efficiently > > If your platform supports multiple currencies, organize internal accounts by currency in your UI: > > ```javascript theme={null} > const accountsByCurrency = accounts.data.reduce((acc, account) => { > const code = account.balance.currency.code; > acc[code] = account; > return acc; > }, {}); > > // Quick lookup: accountsByCurrency['USD'] > > ``` > [accordion] Cache account data appropriately > > Internal account details (especially funding instructions) rarely change, so you can cache them safely. However, always fetch fresh balance data before initiating transfers. ## External Accounts **External accounts** represent bank accounts, crypto wallets, or other payment instruments outside of Grid for on-ramping or off-ramping funds. ### Characteristics * Represent real-world accounts (bank accounts, crypto wallets) * Used as funding sources or payout destinations * Subject to verification and compliance screening * Have associated beneficiary information * Status indicates readiness for use ### Supported Account Types > [tab] US Bank Accounts > > ```json theme={null} > { > "accountType": "USD_ACCOUNT", > "currency": "USD", > "accountNumber": "1234567890", > "routingNumber": "110000000", > "bankAccountType": "CHECKING", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Alice Johnson", > "address": { > "line1": "123 Main St", > "city": "San Francisco", > "state": "CA", > "postalCode": "94105", > "country": "US" > } > } > } > ``` > [tab] EUR Account (Europe) > > ```json theme={null} > { > "accountType": "EUR_ACCOUNT", > "currency": "EUR", > "iban": "DE89370400440532013000", > "bankName": "Deutsche Bank", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Hans Schmidt" > } > } > ``` > > Grid derives `swiftCode` from the IBAN when possible. If automatic derivation > is unavailable, provide it in the request. Responses may include the derived > value. > [tab] PIX (Brazil) > > ```json theme={null} > { > "accountType": "BRL_ACCOUNT", > "currency": "BRL", > "pixKey": "user@example.com", > "pixKeyType": "EMAIL", > "bankName": "Nubank", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Maria Silva", > "taxId": "12345678900" > } > } > ``` > [tab] CLABE (Mexico) > > ```json theme={null} > { > "accountType": "MXN_ACCOUNT", > "clabeNumber": "012345678901234567", > "bankName": "BBVA Mexico", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Carlos Rodriguez" > } > } > ``` > [tab] UPI (India) > > ```json theme={null} > { > "accountType": "INR_ACCOUNT", > "currency": "INR", > "vpa": "user@paytm", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Priya Sharma" > } > } > ``` > [tab] NGN (Nigeria) > > ```json theme={null} > { > "accountType": "NGN_ACCOUNT", > "currency": "NGN", > "accountNumber": "0123456789", > "bankName": "First Bank of Nigeria", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Chukwuemeka Okonkwo" > } > } > ``` > [tab] CAD (Canada) > > ```json theme={null} > { > "accountType": "CAD_ACCOUNT", > "currency": "CAD", > "bankCode": "001", > "branchCode": "00012", > "accountNumber": "1234567", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Emily Thompson" > } > } > ``` > [tab] Spark Wallet (Crypto) > > ```json theme={null} > { > "accountType": "SPARK_WALLET", > "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu", > "currency": "BTC" > } > ``` ### Creating External Accounts ```bash Creating a US bank account 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:019542f5-b3e7-1d02-0000-000000000001", "currency": "USD", "accountInfo": { "accountType": "USD_ACCOUNT", "accountNumber": "9876543210", "routingNumber": "110000000", "bankAccountType": "CHECKING", "beneficiary": { "beneficiaryType": "INDIVIDUAL", "fullName": "Alice Johnson", "address": { "line1": "123 Main St", "city": "San Francisco", "state": "CA", "postalCode": "94105", "country": "US" } } } }' ``` **Response:** ```json theme={null} { "id": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "currency": "USD", "status": "PENDING", "accountInfo": { "accountType": "USD_ACCOUNT", "accountNumber": "****3210", "routingNumber": "110000000" } } ``` ### External Account Status External accounts progress through verification: ``` PENDING → ACTIVE ``` * **PENDING**: Created, undergoing verification/screening * **ACTIVE**: Verified, ready for transactions * **INACTIVE**: Deleted, by you or by Lightspark. This is final: an inactive account can't be reactivated or used for payments * **UNDER\_REVIEW**: Additional review required You'll receive `EXTERNAL_ACCOUNT.STATUS_UPDATED` webhooks when account status changes (e.g., PENDING to ACTIVE, or ACTIVE to UNDER\_REVIEW). If the receiving bank corrects the account details via an ACH notification of change (NOC), you'll receive `EXTERNAL_ACCOUNT.ACCOUNT_INFO_UPDATED` with the corrected `accountInfo`. ### Using External Accounts > [tab] As Destination (Payout) > > Send funds from internal account to external account. Use `/quotes` for both > same-currency and cross-currency payouts; `immediatelyExecute` moves the funds > in the same request: > > ```bash theme={null} > POST /quotes > > { > "source": {"sourceType": "ACCOUNT", "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965"}, > "destination": {"destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123"}, > "lockedCurrencySide": "SENDING", > "lockedCurrencyAmount": 100000, > "immediatelyExecute": true > } > ``` > > Omit `immediatelyExecute` to review the rate and fees first, then call > `POST /quotes/{quoteId}/execute`. > [tab] As Source (Funding) > > Pull funds from external account to internal account: > > ```bash theme={null} > POST /quotes > > { > "source": {"sourceType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123"}, > "destination": {"destinationType": "ACCOUNT", "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965"}, > "lockedCurrencySide": "SENDING", > "lockedCurrencyAmount": 100000, > "immediatelyExecute": true > } > ``` > > Only works for "pullable" external accounts (e.g., debit cards). *** ## Account Combinations Common patterns for combining internal and external accounts: ### Pattern 1: Prefunded Payouts **Use case:** Send payouts from customer's prefunded balance ``` Customer Internal Account (USD) → External Bank Account (EUR) ``` 1. Customer funds internal account via ACH 2. Create quote: Internal USD → External EUR 3. Execute quote 4. Recipient receives EUR in their bank ### Pattern 2: JIT Funded Payouts **Use case:** On-demand payments without maintaining balance ``` Customer → Payment Instructions → Internal Account → External Account ``` 1. Create quote with `source: {customerId}` 2. Grid provides payment instructions 3. Customer sends funds to instructions 4. Quote auto-executes when received 5. Recipient receives payout ### Pattern 3: Platform Rewards **Use case:** Platform distributes Bitcoin rewards ``` Platform Internal Account (USD) → Customer External Wallet (BTC) ``` 1. Platform funds its own internal USD account 2. Create quote: Platform Internal USD → Customer Spark Wallet 3. Execute with `immediatelyExecute: true` 4. Customer receives BTC instantly ### Pattern 4: Crypto On-Ramp **Use case:** Customer buys Bitcoin ``` Customer External Bank → Customer Internal Account (USD) → Customer External Wallet (BTC) ``` 1. Customer links bank account 2. Pull funds to internal USD account 3. Create quote: Internal USD → External Spark Wallet 4. Execute quote 5. BTC delivered to customer's wallet # Currencies & Payment Rails Source: https://agents-docs.lightspark.com/platform-overview/core-concepts/currencies-and-rails Supported currencies, countries, and payment methods Grid supports a wide range of fiat currencies and cryptocurrencies, with automatic routing across optimal payment rails based on currency, destination, and speed requirements. ## Supported Currencies ![](/images/icons/coins.svg) Cryptocurrency Available Globally Bitcoin (BTC) and Stablecoin transactions supported worldwide with no geographic restrictions. ![](/images/icons/blocks.svg) Full Platform Access United States & Europe Onboard as a platform with complete access to APIs, hosted KYC/KYB, dashboard, and business integrations. Send payments to 64 countries on local banking rails, in addition to BTC and stablecoins. ![](/images/icons/globe.svg) Local Payment Rails 64 Countries Receive payments via local rails like SEPA, PIX, UPI, and more. | Country | ISO Code | Payment Rails | | - | - | -: | | 🇦🇹 Austria | AT | `SEPA` `SEPA Instant` `SWIFT` | | 🇧🇩 Bangladesh | BD | `Bank Transfer` `Mobile Money` (bKash, Nagad) `SWIFT` | | 🇧🇪 Belgium | BE | `SEPA` `SEPA Instant` `SWIFT` | | 🇧🇯 Benin | BJ | `Mobile Money` (MTN, Moov) `SWIFT` | | 🇧🇷 Brazil | BR | `PIX` `SWIFT` | | 🇧🇬 Bulgaria | BG | `SEPA` `SEPA Instant` `SWIFT` | | 🇨🇲 Cameroon | CM | `Mobile Money` (Orange, MTN) `SWIFT` | | 🇨🇳 China | CN | `AliPay` `WeChat Pay` `Bank Transfer` `SWIFT` | | 🇨🇴 Colombia | CO | `Bank Transfer` `Mobile Money` (Nequi, Daviplata) `SWIFT` | | 🇭🇷 Croatia | HR | `SEPA` `SEPA Instant` | | 🇨🇾 Cyprus | CY | `SEPA` `SEPA Instant` `SWIFT` | | 🇨🇿 Czech Republic | CZ | `SEPA` `SEPA Instant` `SWIFT` | | 🇩🇰 Denmark | DK | `SEPA` `SEPA Instant` `SWIFT` | | 🇪🇬 Egypt | EG | `Bank Transfer` `Mobile Money` (Orange Cash, Vodafone Cash, Etisalat Cash, Meeza) `SWIFT` | | 🇪🇪 Estonia | EE | `SEPA` `SEPA Instant` `SWIFT` | | 🇫🇮 Finland | FI | `SEPA` `SEPA Instant` `SWIFT` | | 🇫🇷 France | FR | `SEPA` `SEPA Instant` `SWIFT` | | 🇩🇪 Germany | DE | `SEPA` `SEPA Instant` `SWIFT` | | 🇬🇭 Ghana | GH | `Bank Transfer` `Mobile Money` (MTN MoMo, AirtelTigo) `SWIFT` | | 🇬🇷 Greece | GR | `SEPA` `SEPA Instant` `SWIFT` | | 🇭🇹 Haiti | HT | `Mobile Money` (MonCash) `SWIFT` | | 🇭🇺 Hungary | HU | `SEPA` `SEPA Instant` `SWIFT` | | 🇮🇸 Iceland | IS | `SEPA` `SEPA Instant` `SWIFT` | | 🇮🇳 India | IN | `UPI` `NEFT` `RTGS` `SWIFT` | | 🇮🇩 Indonesia | ID | `Bank Transfer` `SWIFT` | | 🇮🇪 Ireland | IE | `SEPA` `SEPA Instant` `SWIFT` | | 🇮🇱 Israel | IL | `Bank Transfer` `SWIFT` | | 🇮🇹 Italy | IT | `SEPA` `SEPA Instant` `SWIFT` | | 🇨🇮 Ivory Coast | CI | `Mobile Money` (Wave) `SWIFT` | | 🇰🇪 Kenya | KE | `Mobile Money` (M-PESA) `SWIFT` | | 🇱🇻 Latvia | LV | `SEPA` `SEPA Instant` `SWIFT` | | 🇱🇮 Liechtenstein | LI | `SEPA` `SEPA Instant` `SWIFT` | | 🇱🇹 Lithuania | LT | `SEPA` `SEPA Instant` `SWIFT` | | 🇱🇺 Luxembourg | LU | `SEPA` `SEPA Instant` `SWIFT` | | 🇲🇼 Malawi | MW | `Mobile Money` (Airtel, TNM) `SWIFT` | | 🇲🇾 Malaysia | MY | `Bank Transfer` `SWIFT` | | 🇲🇹 Malta | MT | `SEPA` `SEPA Instant` `SWIFT` | | 🇲🇽 Mexico | MX | `SPEI` `SWIFT` | | 🇳🇱 Netherlands | NL | `SEPA` `SEPA Instant` `SWIFT` | | 🇳🇬 Nigeria | NG | `Bank Transfer` `SWIFT` | | 🇳🇴 Norway | NO | `SEPA` `SEPA Instant` `SWIFT` | | 🇵🇰 Pakistan | PK | `Bank Transfer` `Mobile Money` (JazzCash, EasyPaisa) `SWIFT` | | 🇵🇭 Philippines | PH | `Bank Transfer` `SWIFT` | | 🇵🇱 Poland | PL | `SEPA` `SEPA Instant` `SWIFT` | | 🇵🇹 Portugal | PT | `SEPA` `SEPA Instant` `SWIFT` | | 🇷🇴 Romania | RO | `SEPA` `SEPA Instant` `SWIFT` | | 🇷🇼 Rwanda | RW | `Mobile Money` (MTN, Airtel) `SWIFT` | | 🇸🇳 Senegal | SN | `Mobile Money` (Orange Money, Wave) `SWIFT` | | 🇸🇬 Singapore | SG | `PayNow` `FAST` `Bank Transfer` `SWIFT` | | 🇸🇰 Slovakia | SK | `SEPA` `SEPA Instant` `SWIFT` | | 🇸🇮 Slovenia | SI | `SEPA` `SEPA Instant` | | 🇿🇦 South Africa | ZA | `Bank Transfer` `SWIFT` | | 🇪🇸 Spain | ES | `SEPA` `SEPA Instant` `SWIFT` | | 🇸🇪 Sweden | SE | `SEPA` `SEPA Instant` `SWIFT` | | 🇨🇭 Switzerland | CH | `SEPA` `SEPA Instant` `SWIFT` | | 🇹🇿 Tanzania | TZ | `Mobile Money` (Airtel, Vodacom) `SWIFT` | | 🇹🇭 Thailand | TH | `Bank Transfer` `SWIFT` | | 🇹🇷 Turkey | TR | `Bank Transfer` `SWIFT` | | 🇺🇬 Uganda | UG | `Mobile Money` (Airtel, MTN) `SWIFT` | | 🇦🇪 United Arab Emirates | AE | `Bank Transfer` `SWIFT` | | 🇬🇧 United Kingdom | GB | `Faster Payments` `SWIFT` | | 🇺🇸 United States | US | `ACH` `Same-day ACH` `Wire Transfer` `RTP` `FedNow` `SWIFT` | | 🇻🇳 Vietnam | VN | `Bank Transfer` `SWIFT` | | 🇿🇲 Zambia | ZM | `Mobile Money` (Airtel, MTN, Zamtel) `SWIFT` | > [!NOTE] > > `SWIFT` is the international-wire fallback, available in USD and EUR to nearly every > destination — including the countries above, and many not listed here that have no local Grid > rail. Prefer the local rail where one exists: it is faster and cheaper. `SWIFT` cannot reach > sanctioned or prohibited destinations; Croatia and Slovenia are the two countries in this table > it does not cover, and both are fully served by `SEPA`. Contact your Lightspark account manager > to confirm coverage for a specific corridor. > [!NOTE] > > `Mobile Money` pays a wallet held against the beneficiary's phone number rather than a bank > account. Create the external account with `phoneNumber` (and, in corridors served by more than > one wallet, the `provider` or `bankName` that selects it) instead of an account number. > Corridors that list both rails accept either — pick the one your beneficiary holds. > > The wallets in parentheses show which providers a corridor reaches. Use the > [Discoveries API](/api-reference/discoveries/list-available-receiving-institution-names) for the > exact accepted values and current coverage, since wallet availability changes over time. > [!NOTE] > > China has two rails serving different beneficiaries. `AliPay` and `WeChat Pay` are > consumer wallet payouts — use these to pay an individual. `Bank Transfer` is > business-to-business only, so it cannot be used to pay an individual. Regional Summary ![](/images/icons/globe.svg) Europe Primary: SEPA/SEPA Instant 32 countries ![](/images/icons/globe.svg) Middle East and Africa Primary: Mobile Money and Bank Transfer 17 countries ![](/images/icons/globe.svg) Asia-Pacific Various instant payment systems 10 countries ![](/images/icons/globe.svg) Americas PIX, SPEI, ACH, FedNow 5 countries ## Coming Soon Grid is rapidly expanding. These corridors are next on our roadmap — get in touch to be first in line when they go live. | Country | ISO Code | | - | - | | 🇨🇦 Canada | CA | | 🇨🇳 China | CN | | 🇭🇰 Hong Kong | HK | | 🇰🇷 South Korea | KR | > [!NOTE] > > China already has live coverage in the table above, via Thunes (`AliPay` `WeChat Pay` `Bank Transfer`). > The entry here is a separate, in-progress corridor through a different provider. It doesn't reduce or > replace the existing China coverage. > [!NOTE] > > Grid automatically selects the optimal rail based on currency, country, and amount. You don't need to specify the rail in your API requests. For destinations without a local rail, create a SWIFT external account to pay out over international wire — see [External Accounts](/payouts-and-b2b/depositing-funds/external-accounts). ## Currency Conversion Grid handles currency conversion automatically through the quote system: ### Exchange Rates * **Real-time rates**: Grid provides live exchange rates based on market conditions * **Rate locking**: Quotes lock rates for 1-15 minutes depending on payment type * **Transparency**: Exact rates and fees shown before execution * **No hidden fees**: What you see in the quote is what you pay ### Example Conversion Flow ```bash theme={null} POST /quotes { "source": {"sourceType": "ACCOUNT", "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965"}, "destination": {"destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123"}, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 100000 } ``` **Response:** > [accordion] Response > > ```json theme={null} > { > "id": "Quote:019542f5-b3e7-1d02-0000-000000000020", > "status": "PROCESSING", > "createdAt": "2025-10-03T15:00:00Z", > "expiresAt": "2025-10-03T15:05:00Z", > "source": { > "sourceType": "ACCOUNT", > "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" > }, > "destination": { > "destinationType": "ACCOUNT", > "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" > }, > "sendingCurrency": { > "code": "USD", > "name": "United States Dollar", > "symbol": "$", > "decimals": 2 > }, > "receivingCurrency": { > "code": "MXN", > "name": "Mexican Peso", > "symbol": "$", > "decimals": 2 > }, > "totalSendingAmount": 100000, > "totalReceivingAmount": 1716375, > "exchangeRate": 0.05797101, > "feesIncluded": 500, > "platformFeesIncluded": 0, > "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000025" > } > ``` This shows: Send $1,000 → Receive $17,163.75 (1 USD ≈ 17.25 MXN, or exchangeRate: 0.05797101), \$5 fee. # Entities & Relationships Source: https://agents-docs.lightspark.com/platform-overview/core-concepts/entities Understanding Grid's core data model There are several key entities in the Grid API: **Platform**, **Customers**, **Internal Accounts**, **External Accounts**, **Quotes**, **Transactions**, and **UMA Addresses**. ![Entity relationships diagram showing platform, customers, internal accounts, external accounts, transactions, and UMA addresses](https://mintcdn.com/agents-docs/qL8kqlSYbvexR8cg/images/entities-light.svg?fit=max\&auto=format\&n=qL8kqlSYbvexR8cg\&q=85\&s=e5d51454ebea529d7d9219a140ab7e1e) ## Businesses, People, and Accounts ### Platform Your **platform** is you! It's the top-level entity that integrates with the Grid API. The platform: * Has its own configuration (webhook endpoint, supported currencies, API tokens, etc.) * A platform can have many customers both business and individual * Manages multiple customers and their accounts * Can hold platform-owned internal accounts for settlement and liquidity management * Acts as the integration point between your application and the open Money Grid ### Customers **Customers** are your end users who send and receive payments through your platform. Each customer: * Can be an individual or business entity * Has a KYC/KYB status that determines their ability to transact. If you are a regulated financial institution, this will typically be `APPROVED` since you do the KYC/KYB yourself. * Is identified by both a system-generated ID and optionally your platform-specific customer ID * May have associated internal accounts and external accounts * May have a unique **UMA address** (e.g., `$john.doe@yourdomain.com`). If you don't assign an UMA address when creating a customer, they will be assigned a system-generated one. ### Internal Accounts **Internal accounts** are Grid-managed accounts that hold balances in specific currencies. They can belong to either: * **Platform internal accounts** - Owned by the platform for settlement, liquidity, and float management * **Customer internal accounts** - Associated with specific customers for holding funds Internal accounts: * Have balances in a single currency (USD, EUR, MXN, etc.) * Can be funded via bank transfers or crypto deposits using payment instructions * Are used as sources or destinations for transactions instantly 24/7/365 * Track available balance for sending payments or receiving funds > [!NOTE] > > **Global Accounts** are a special kind of internal account, powered by a self-custody embedded [Spark](https://spark.money) wallet that Grid provisions for your customers to hold a stablecoin balance. They behave like any other internal account on the way in, but every outbound transfer must be authorized by a customer signature. See [Global Accounts](/payouts-and-b2b/global-accounts/overview) for the full overview. ### External Accounts **External accounts** are traditional bank accounts, crypto wallets, or other payment instruments connected to customers for on-ramping or off-ramping funds. Each external account: * Are associated with a specific customer or the platform * Represents a real-world bank account (with routing number, account number, IBAN, etc.), wallet, or payment instrument * Has an associated beneficiary (individual or business) who receives payments from the customer or platform * Has a status indicating screening status (ACTIVE, PENDING, INACTIVE, etc.) * Can be used as a destination for quote-based transfers or same currency transfers like withdrawals * For pullable sources like debit cards or ACH pulls, an external account can be used as a source for transfers-in to fund internal accounts or to fund cross-border transfers via quotes. ## Entity Examples by Use Case Understanding how entities map to your specific use case helps clarify your integration architecture. Here are common examples: ### B2B Payouts Platform (e.g., Bill.com, Routable) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The payouts platform itself | Your company providing AP automation | | **Customer** | Businesses sending payments to vendors | Acme Corp (your client company) | | **External Account** | Vendors/suppliers receiving payments | Office supply vendor, freelance contractor | **Flow**: Acme Corp (customer) uses your platform to pay their vendor invoices → funds move from Acme's internal account → to vendor's external bank account ### Direct Rewards Platform (Platform-Funded Model) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The app paying rewards directly to users | Your cashback app | | **Customer** | (Not used in this model) | N/A | | **External Account** | End users' crypto wallets receiving rewards | Sarah's self-custody Bitcoin wallet | **Flow**: Your platform sends micro-payouts directly from platform internal accounts → to users' external crypto wallets at scale. Common for cashback apps where the platform earns affiliate commissions and shares them with users. ### White-Label Rewards Platform (Customer-Funded Model) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The rewards infrastructure provider | Your white-label rewards API | | **Customer** | Brands or merchants running reward campaigns | Nike, Starbucks | | **External Account** | End users' crypto wallets receiving rewards | Sarah's self-custody Bitcoin wallet | **Flow**: Nike (customer) funds their internal account → your platform sends rewards on their behalf → to users' external crypto wallets. Common for brand loyalty programs where merchants manage their own reward budgets. ### Remittance/P2P App (e.g., Wise, Remitly) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The remittance service | Your money transfer app | | **Customer** | Both sender and recipient of funds | Maria (sender in US), Juan (recipient in Mexico) | | **External Account** | Bank accounts for funding/receiving | Maria's US bank (funding), Juan's Mexican bank (receiving funds) | **Flow**: Maria (customer) funds transfer from her external account → to Juan (also a customer) → who receives funds in his external bank account. Alternatively, Maria could send to Juan's UMA address directly. ## Transactions and Addressing Entities ### Quotes **Quotes** provide locked-in exchange rates and payment instructions for transfers. A quote: * Specifies a source (internal account, customer ID, or the platform itself) and destination (internal/external account or UMA address) * Locks an exchange rate for a short period (typically 1-5 minutes) or can be immediately executed with the `immediatelyExecute` flag * Calculates total fees and amounts for currency conversion * Provides payment instructions for funding the transfer if needed, or can be funded via an internal account balance. * Must be executed before it expires * Creates a transaction when executed ### Transactions **Transactions** represent completed or in-progress payment transfers. Each transaction: * Has a type (INCOMING or OUTGOING), keyed on whether the destination is an internal account * Has a status (PENDING, COMPLETED, FAILED, etc.) * References a customer (sender for outgoing, recipient for incoming) or a platform internal account * Specifies source and destination (accounts or UMA addresses) * Includes amounts, currencies, and settlement information * May include counterparty information for compliance purposes if required by your platform configuration Transactions are created when a quote is executed, incoming or outgoing. This covers same-currency and cross-currency transfers alike. ### UMA Addresses (optional) **UMA addresses** are human-readable payment identifiers that follow the format `$username@domain.com`. They: * Uniquely identify entities on the Grid network * Enable sending and receiving payments across different platforms without knowing the recipient's underlying account details or personal information * Support currency negotiation and cross-border transfers * Work similar to email addresses but for payments * Are an optional UX improvement for some use cases. Use of UMA addresses is not required in order to use the Grid API. ## Platform Configuration Your **platform configuration** defines global settings for your Grid integration: * **Webhook endpoint**: URL where Grid sends event notifications * **Supported currencies**: Which currencies your platform offers * **UMA domain** (optional): Your domain for UMA addresses (e.g., `yourplatform.com`) * **Required counterparty fields** (UMA only): Information required from senders for compliance Platform configuration is managed via the [dashboard](https://app.lightspark.com). ## ID Format All Grid entities use a consistent ID format: ``` EntityType:UUID ``` Examples: * `Customer:019542f5-b3e7-1d02-0000-000000000001` * `InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965` * `Transaction:019542f5-b3e7-1d02-0000-000000000030` * `Quote:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123` This format makes IDs self-documenting and easier to debug. ## Platform Customer ID When creating customers, you can provide your own `platformCustomerId`: ```json theme={null} { "platformCustomerId": "user_12345_from_my_system", "customerType": "INDIVIDUAL", "fullName": "Alice Johnson" } ``` This allows you to: * Map Grid customers to your internal user IDs * Query transactions by your own customer identifiers * Maintain referential integrity between systems Grid returns both IDs in all customer-related responses: ```json theme={null} { "id": "Customer:019542f5-b3e7-1d02-0000-000000000001", "platformCustomerId": "user_12345_from_my_system" } ``` ## Amount Representation All monetary amounts in Grid are represented in the **smallest currency unit**: > [tab] Fiat Currencies > > **Cents, pence, centavos, etc.** > > ```json theme={null} > { > "amount": 50000, > "currency": { > "code": "USD", > "symbol": "$", > "decimals": 2 > } > } > ``` > > This represents **\$500.00** (50000 cents) > [tab] Cryptocurrencies > > **Satoshis for Bitcoin** > > ```json theme={null} > { > "amount": 10000000, > "currency": { > "code": "BTC", > "symbol": "₿", > "decimals": 8 > } > } > ``` > > This represents **0.1 BTC** (10,000,000 satoshis) The `decimals` field tells you how many decimal places to use when displaying the amount: ```javascript theme={null} function formatAmount(amount, currency) { const value = amount / Math.pow(10, currency.decimals); return `${currency.symbol}${value.toFixed(currency.decimals)}`; } // formatAmount(50000, {code: "USD", symbol: "$", decimals: 2}) → "$500.00" // formatAmount(10000000, {code: "BTC", symbol: "₿", decimals: 8}) → "₿0.10000000" ``` ## Timestamps All timestamps in Grid use **ISO 8601 format with UTC timezone**: ```json theme={null} { "createdAt": "2025-10-03T15:00:00Z", "settledAt": "2025-10-03T15:30:00Z" } ``` Key timestamp fields: * **createdAt**: When the entity was created * **updatedAt**: Last modification time * **settledAt**: When a transaction completed (transactions only) * **expiresAt**: When a quote expires (quotes only) # Quote System Source: https://agents-docs.lightspark.com/platform-overview/core-concepts/quote-system How exchange rates, pricing, and payment execution work Quotes are Grid's mechanism for providing locked-in exchange rates, transparent fee calculations, and payment instructions. Every transfer goes through a quote, whether or not a currency conversion is involved. ## What is a Quote? A **quote** locks in: * An exchange rate between two currencies * Total fees for the transaction * Exact amounts to be sent and received * Payment instructions (if JIT funding is needed) * An expiration time (typically 1-5 minutes, up to 15 minutes depending on the payment type) Quotes ensure that your customers know exactly what they'll pay and what the recipient will receive before committing to a transaction. ## When Do You Need a Quote? Use `POST /quotes` for every transfer: * **Cross-currency transfers** (USD → EUR, BRL → MXN) * **Fiat-to-crypto conversion** (USD → BTC) * **Crypto-to-fiat conversion** (BTC → USD) * **UMA payments** * **JIT funded payments** (need payment instructions) * **Same-currency transfers** (USD → USD), including pulling funds from a pullable external account into an internal account For a same-currency transfer there is no exchange rate to lock, so the two-step create-then-execute flow rarely buys you anything. Set `immediatelyExecute` to `true` and the quote is created and executed in a single request. > [!NOTE] > > The `/transfer-out` and `/transfer-in` endpoints previously served same-currency > transfers and are now deprecated. They still work and their request and response > shapes are unchanged, but new integrations should use `/quotes`. ## Creating a Quote ### Basic Cross-Currency Quote This is a basic quote for a cross-currency transfer from an internal account to an external account, which were pre-created as described in the [Account Model](/platform-overview/core-concepts/account-model) section. ```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:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 100000 }' ``` **Response:** ```json theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000020", "status": "PROCESSING", "createdAt": "2025-10-03T15:00:00Z", "expiresAt": "2025-10-03T15:05:00Z", "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "sendingCurrency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 }, "receivingCurrency": { "code": "BTC", "name": "Bitcoin", "symbol": "₿", "decimals": 8 }, "totalSendingAmount": 100000, "totalReceivingAmount": 828835, "exchangeRate": 120048.01920768, "feesIncluded": 500, "platformFeesIncluded": 0, "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000025" } ``` This quote says: Send \$1,000 USD, recipient receives 0.00828835 BTC, fees are \$5.00, expires in 5 minutes. ### Locked Currency Side You can lock either the **sending** or **receiving** amount: > [tab] Lock Sending Amount > > **Use when:** Customer knows exactly how much they want to send > > ```json theme={null} > { > "lockedCurrencySide": "SENDING", > "lockedCurrencyAmount": 100000 > } > ``` > > Grid calculates what the recipient will receive based on current exchange rates. > [tab] Lock Receiving Amount > > **Use when:** Recipient must receive an exact amount (e.g., invoice payment) > > ```json theme={null} > { > "lockedCurrencySide": "RECEIVING", > "lockedCurrencyAmount": 92000 > } > ``` > > Grid calculates how much the sender must send to ensure recipient gets exactly €920. ## Funding Models Grid supports two funding models for quotes: ### Prefunded (From Internal Account) Source is an existing internal account with available balance: ```json theme={null} { "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" } } ``` * Funds are debited immediately when quote is executed * No payment instructions needed * Best for: Customers with pre-loaded balances ### Just-In-Time (JIT) Funding Source is the customer ID or the platform itself — Grid provides payment instructions: ```json theme={null} { "source": { "sourceType": "REALTIME_FUNDING", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "currency": "USD" } } ``` **Quote response includes payment instructions:** ```json theme={null} { "id": "Quote:...", "paymentInstructions": [ { "instructionsNotes": "Please ensure the reference code is included in the payment memo/description field", "accountOrWalletInfo": { "reference": "UMA-Q12345-REF", "accountType": "USD_ACCOUNT", "accountNumber": "9876543210", "routingNumber": "110000000", "bankAccountType": "CHECKING", "bankName": "Chase Bank" } }, { "accountOrWalletInfo": { "accountType": "SOLANA_WALLET", "assetType": "USDC", "address": "4Nd1m6Qkq7RfKuE5vQ9qP9Tn6H94Ueqb4xXHzsAbd8Wg" } } ] } ``` * Customer sends funds to provided account with reference * Quote executes automatically when Grid receives payment * Best for: On-demand payments without maintaining balances ## Executing a Quote For a prefunded quote or one from a pullable external account source, once a quote is created, execute it before it expires: ```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" ``` **Response:** ```json theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000050", "status": "PROCESSING", "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000060", "totalSendingAmount": 100000, "totalReceivingAmount": 91540, "feesIncluded": 500, "platformFeesIncluded": 0 } ``` ### Strong Customer Authentication (EU Customers) For customers in regions where Strong Customer Authentication (SCA) is required (e.g., EU), quotes may enter a `PENDING_AUTHORIZATION` status with an `scaChallenge` object. The transfer is not released until the customer authorizes it. Where the challenge appears depends on how the quote is funded: * **Prefunded** — `POST /quotes/{quoteId}/execute` returns `PENDING_AUTHORIZATION` with the challenge instead of initiating the transfer. The challenge does not exist until `execute` is called, so the proof always goes on the follow-up authorize; re-calling `execute` returns `409`. * **Realtime (JIT) funded** — the challenge arrives earlier and `paymentInstructions` are withheld until it is satisfied. **Quote response with SCA challenge:** ```json theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000050", "status": "PENDING_AUTHORIZATION", "scaChallenge": { "id": "ScaChallenge:...", "expiresAt": "2025-10-03T12:05:00Z", "factor": "SMS_OTP", "availableFactors": ["SMS_OTP", "PASSKEY"], "purpose": "PAYOUT" } } ``` To authorize the quote, call `POST /quotes/{quoteId}/authorize` with the appropriate proof: ```bash theme={null} curl -X POST https://api.lightspark.com/grid/2025-10-13/quotes/Quote:abc123/authorize \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{"code": "000000"}' ``` > [!NOTE] > > Multiple authorizations may be required in sequence (e.g., currency conversion + payout). After each authorization, check the quote status — if still `PENDING_AUTHORIZATION`, authorize the next challenge. ### Execution Timing > [tab] Prefunded > > * Transaction created with status `PENDING` > * Funds debited from source account immediately > * Settlement begins right away > [tab] JIT Funded > > * Quote waits for payment receipt > * Once Grid receives payment with correct reference > * Quote executes automatically > * Transaction created and settlement begins ## Immediate Execution For **market rate execution** without quote approval, use the `immediatelyExecute` flag: ```bash Immediate quote execution 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:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965"}, "destination": {"destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123"}, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 100000, "immediatelyExecute": true }' ``` > [accordion] Response > > ```json theme={null} > { > "id": "Quote:019542f5-b3e7-1d02-0000-000000000020", > "status": "COMPLETED", > "createdAt": "2025-10-03T15:00:00Z", > "expiresAt": "2025-10-03T15:05:00Z", > "source": { > "sourceType": "ACCOUNT", > "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965" > }, > "destination": { > "destinationType": "ACCOUNT", > "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" > }, > "sendingCurrency": { > "code": "USD", > "name": "United States Dollar", > "symbol": "$", > "decimals": 2 > }, > "receivingCurrency": { > "code": "EUR", > "name": "Euro", > "symbol": "€", > "decimals": 2 > }, > "totalSendingAmount": 100000, > "totalReceivingAmount": 91540, > "exchangeRate": 1.08695652, > "feesIncluded": 500, > "platformFeesIncluded": 0, > "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000025" > } > ``` * Quote is created and executed in one API call * Best for: Rewards distribution, micro-payments, time-sensitive transfers > [!NOTE] > > `immediatelyExecute` can only be used for quotes with a source that is either an internal account or has direct pull functionality (e.g., ACH pull with an external account). > [!WARNING] > > Customer doesn't see rate before execution. If you want to lock a quote and confirm fees and exchange rate details before executing the quote, set `immediatelyExecute` to `false` or omit the field. ## Fees All fees are transparently displayed in the quote response: ```json theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000020", "feesIncluded": 500, "platformFeesIncluded": 0, ... quote response ... "rateDetails": { "counterpartyMultiplier": 1.08, "counterpartyFixedFee": 10, "gridApiMultiplier": 0.925, "gridApiFixedFee": 10, "gridApiVariableFeeRate": 0.003, "gridApiVariableFeeAmount": 300 } } ``` **Rate Details Breakdown:** * **`counterpartyMultiplier`**: Exchange rate from mSATs to receiving currency (1.08 = 1 mSAT = 1.08 cents EUR) * **`counterpartyFixedFee`**: Fixed fee charged by counterparty (10 cents EUR) * **`gridApiMultiplier`**: Exchange rate from sending currency to mSATs including variable fees (0.925 = \$1 USD = 0.925 mSATs) * **`gridApiFixedFee`**: Fixed fee charged by Grid API (10 cents USD) * **`gridApiVariableFeeRate`**: Variable fee rate as percentage (0.003 = 0.3%) * **`gridApiVariableFeeAmount`**: Variable fee amount (300 cents USD for \$1,000 transaction) Fees are deducted from the sending amount, so: * **Customer sends**: \$1,000 * **Fees**: \$5.00 * **Amount converted**: \$995.00 * **Recipient receives**: €915.40 (1 USD ≈ 0.92 EUR, or exchangeRate: 1.08695652) > [!NOTE] > > Quoted fees may fluctuate between quotes. Some fee components (e.g. > `counterpartyFixedFee` on outgoing transfers, and `gridApiFixedFee` on > incoming transfers) are denominated in the receiving currency, so their > value in the sending currency moves with the FX rate. Always reference the > fees in the latest quote response — they are locked only for the lifetime > of that quote. ### How the variable fee is applied The variable fee (`gridApiVariableFeeRate`) is always assessed as a fraction of the **sending** amount. How that plays out depends on which side of the quote you lock (see [Locked Currency Side](#locked-currency-side)). **Sending amount locked** (`lockedCurrencySide: "SENDING"`) — the fee is taken out of the amount you send, and the recipient receives the remainder: ```text theme={null} received = send − send × fee ``` Send \$50 at a 10 bps (0.10%, `fee = 0.001`) rate: `received = 50 − 50 × 0.001 = 49.95`. **Receiving amount locked** (`lockedCurrencySide: "RECEIVING"`) — Grid solves for the send amount that nets the exact received amount *after* the same fee: ```text theme={null} send = received / (1 − fee) ``` Receive \$50 at 10 bps: `send = 50 / 0.999 = 50.05005005…`. > [!NOTE] > > Receiver-locked quotes divide by `(1 − fee)` rather than adding `received × fee` so that locking the receiving side is never cheaper than locking the sending side at the same rate. Solving `send × (1 − fee) = received` for `send` keeps the effective fee rate identical across both locked sides. For cross-currency quotes the exchange rate applies on top of this: the fee is taken on the sending currency, then the net amount is converted at the quoted rate. The same-currency examples above (for example USDC → USD at 1:1) show the fee in isolation. ## Best Practices > [accordion] Always show the quote to customers before executing > > Let customers review the exchange rate, fees, and final amounts before committing: > > ```javascript theme={null} > // ✅ Good: Show quote details, await confirmation > const quote = await createQuote(params); > showQuoteToUser(quote); > if (await userConfirms()) { > await executeQuote(quote.id); > } > > // ❌ Bad: Immediate execution without review (unless micro-payments/rewards) > await createQuote({...params, immediatelyExecute: true}); > ``` > [accordion] Handle expiration gracefully > > Store quote parameters so you can recreate expired quotes: > > ```javascript theme={null} > const quoteParams = { > source: {accountId: customerAccount}, > destination: {accountId: recipientAccount}, > lockedCurrencySide: 'SENDING', > lockedCurrencyAmount: amount > }; > > let quote = await createQuote(quoteParams); > > // Later, if expired... > try { > await executeQuote(quote.id); > } catch (error) { > if (error.code === 'QUOTE_EXPIRED' || error.code === 'QUOTE_RATE_UNAVAILABLE') { > quote = await createQuote(quoteParams); // Recreate with fresh rate > await executeQuote(quote.id); > } > } > ``` > > `QUOTE_RATE_UNAVAILABLE` means the quoted rate was refused when the quote was > executed, rather than the quote's own expiry window elapsing. Nothing was > exchanged in either case, and the recovery is the same: create a new quote. > [accordion] Monitor quote status via webhooks > > Subscribe to quote-related webhooks: > > ```javascript theme={null} > app.post('/webhooks/grid', (req, res) => { > const {data: transaction, type} = req.body; > > if (type.startsWith('OUTGOING_PAYMENT.') && transaction.quoteId) { > // Quote was executed, transaction created > updateCustomerUI(transaction); > } > > res.status(200).send(); > }); > ``` # Transaction Lifecycle Source: https://agents-docs.lightspark.com/platform-overview/core-concepts/transaction-lifecycle Follow a payment from creation to settlement Understanding the transaction lifecycle helps you build robust payment flows, handle edge cases, and provide accurate status updates to your customers. ## Incoming or outgoing Every transfer creates a transaction, and its `type` is keyed on the **destination** — where the money came from makes no difference: | Destination | `type` | Webhook family | | - | - | - | | Internal account | `INCOMING` | `INCOMING_PAYMENT.` | | External account | `OUTGOING` | `OUTGOING_PAYMENT.` | | UMA address | `OUTGOING` | `OUTGOING_PAYMENT.` | In practice: * A payout from an internal account to an external account is `OUTGOING`. * A pull from an external account into an internal account is `INCOMING`, even though you initiated it. * A transfer between two internal accounts is `INCOMING`. * A deposit that lands by paying an internal account's [payment instructions](/payouts-and-b2b/depositing-funds/internal-accounts) is `INCOMING`, as is a payment received at one of your customers' UMA addresses. The type decides which lifecycle below applies and which webhooks you receive, so branch on it rather than on which endpoint you called. > [!NOTE] > > Card spending produces a third type, `CARD`, which follows its own lifecycle. ## Outgoing Transaction Flow **Your customer/platform sends funds to an external recipient.** **1. Create Quote** Lock in exchange rate and fees: ```bash theme={null} POST /quotes { "source": {"sourceType": "ACCOUNT", "accountId": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965"}, "destination": {"destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123"}, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 100000 } ``` **Response:** * Quote ID * Locked exchange rate * Expiration time (typically \~5 minutes or greater, depending on corridor) **2. Execute Quote** Initiate the payment: ```bash theme={null} POST /quotes/{quoteId}/execute ``` **Result:** * Transaction created with status `PENDING` * Source account debited immediately * `OUTGOING_PAYMENT.PENDING` webhook sent **3. Processing** Grid handles: * Currency conversion (if applicable) * Routing to appropriate payment rail * Settlement with destination bank/wallet **Status**: `PROCESSING` **4. Completion or Failure** **Success Path:** * Funds delivered to recipient * Status: `COMPLETED` * `settledAt` timestamp populated * `OUTGOING_PAYMENT.COMPLETED` webhook sent **Failure Path:** * Delivery failed (invalid account, etc.) * Status: `FAILED` * `failureReason` populated * `OUTGOING_PAYMENT.FAILED` webhook sent * Refund initiated automatically — track via the `refund` object and `OUTGOING_PAYMENT.REFUND_*` webhooks Most transactions on Grid are completed in seconds. ## Outgoing Payment Status A single `status` field represents whether the transaction reached its destination: | Status | Description | | - | - | | **PENDING** | Quote is pending confirmation | | **PENDING\_AUTHORIZATION** | Awaiting Strong Customer Authentication. Only occurs for customers in regions where SCA is required (e.g., EU). The challenge lives on the quote, not the transaction — fetch `GET /quotes/{quoteId}` using the transaction's `quoteId`, then authorize its `scaChallenge` with `POST /quotes/{quoteId}/authorize`. | | **EXPIRED** | Quote wasn't executed before the expiry window | | **PROCESSING** | Executing the quote after receiving funds (checking internal balances, or push/pull to/from external account) | | **COMPLETED** | Payout successfully reached the destination account | | **FAILED** | Something went wrong — accompanied by a `failureReason` | > [!WARNING] > > `status` reports where the funds are now. `COMPLETED` means they are where settlement put them; `FAILED` means they were sent back. A bank return can move a `COMPLETED` payment to `FAILED` days after it settled. For an incoming payment, a return the originating bank later dishonors brings the credit back and the payment moves to `COMPLETED` again. A returned payout stays `FAILED`: it has already been refunded to its source, so a dishonored return is reconciled manually rather than reported as a new completion. Only `EXPIRED` is terminal. Apply each status webhook as it arrives, and don't treat `COMPLETED` or `FAILED` as final. > > Each side's `railDetails` reports the transfer on its rail and follows every step of it: an ACH entry's status moves through `SETTLED`, `RETURNED`, `RETURN_DISHONORED` and `RETURN_CONTESTED` with a timestamp for each, whether or not the transaction's `status` changes with it. A transaction stays `COMPLETED` through a return only when a later step that can't be undone, such as a completed conversion, already used the funds; its `railDetails` still shows `RETURNED`. ### State Diagram ```mermaid theme={null} stateDiagram-v2 [*] --> PENDING : Quote created PENDING --> PENDING_AUTHORIZATION : SCA required (EU) PENDING_AUTHORIZATION --> PROCESSING : Authorized PENDING_AUTHORIZATION --> FAILED : Challenge expired unsatisfied PENDING --> PROCESSING : Quote confirmed, funds received PENDING --> EXPIRED : Quote expired before execution PROCESSING --> COMPLETED : Payout reached destination PROCESSING --> FAILED : Payout failed COMPLETED --> FAILED : Bank returned payment FAILED --> REFUND_PENDING : Refund initiated PROCESSING --> REFUND_PENDING : Manual cancellation state "REFUND" as refund { REFUND_PENDING --> REFUND_COMPLETED : Refund settled REFUND_PENDING --> REFUND_FAILED : Refund failed } REFUND_COMPLETED --> COMPLETED : Manual cancellation resolved EXPIRED --> [*] REFUND_COMPLETED --> [*] REFUND_FAILED --> [*] ``` ## Refund Object When a payment fails or is cancelled, refunds are tracked in a dedicated object on the transaction, decoupled from the payment status: | Field | Description | | - | - | | `reference` | Refund reference ID | | `initiatedAt` | Timestamp when refund was initiated | | `settledAt` | Timestamp when refund settled | | `status` | `PENDING`, `COMPLETED`, or `FAILED` | | `reason` | Why the refund occurred — `TRANSACTION_FAILED`, `USER_CANCELLATION`, or `TIMEOUT` | ```json theme={null} { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000030", "status": "FAILED", "type": "OUTGOING", "direction": "DEBIT", "failureReason": "QUOTE_EXECUTION_FAILED", "refund": { "reference": "UMA-Q12345-REFUND", "initiatedAt": "2025-10-03T15:10:00Z", "settledAt": "2025-10-03T15:15:00Z", "status": "COMPLETED", "reason": "TRANSACTION_FAILED" } } ``` ### Refund destination Where a refund lands depends on how the transaction was funded: * **Prefunded (internal account)** — the amount is credited back to the source internal account balance. * **Real-time (JIT) funding via an instant fiat rail** (RTP, PIX, SEPA Instant, etc.) — the refund is returned to the originating funding instrument. * **Real-time (JIT) funding via crypto** (for example USDC or BTC) — the refunded amount is credited to the customer's **internal account balance for that asset**. It is **not** returned to the wallet the funds were sent from. > [!NOTE] > > Crypto deposits are not refunded to the source wallet because Grid cannot verify that the sending wallet belongs to your customer — the funds may have originated from an exchange or another third-party address. The credited internal balance is reusable: spend it with a new quote, or withdraw it to an external wallet with a quote whose destination is that wallet. ## Webhooks Payment webhooks use the format `_PAYMENT.`, matching the transaction's `type`. The webhook request body contains the full transaction resource. ### Outgoing event types | Event | Description | | - | - | | `OUTGOING_PAYMENT.PENDING` | Transaction created, quote pending confirmation | | `OUTGOING_PAYMENT.PENDING_AUTHORIZATION` | Awaiting Strong Customer Authentication (EU customers only) | | `OUTGOING_PAYMENT.PROCESSING` | Quote confirmed, payout in progress | | `OUTGOING_PAYMENT.COMPLETED` | Payout reached destination | | `OUTGOING_PAYMENT.FAILED` | Payout failed | | `OUTGOING_PAYMENT.EXPIRED` | Quote expired before execution | | `OUTGOING_PAYMENT.REFUND_PENDING` | Refund initiated | | `OUTGOING_PAYMENT.REFUND_COMPLETED` | Refund settled | | `OUTGOING_PAYMENT.REFUND_FAILED` | Refund failed | ### Incoming event types | Event | Description | | - | - | | `INCOMING_PAYMENT.PENDING` | Funds are on their way to the internal account | | `INCOMING_PAYMENT.PROCESSING` | Funding received, credit in progress | | `INCOMING_PAYMENT.COMPLETED` | Funds credited to the internal account | | `INCOMING_PAYMENT.FAILED` | The deposit failed, or a bank returned it after it completed | | `INCOMING_PAYMENT.REFUND_PENDING` | Refund initiated | | `INCOMING_PAYMENT.REFUND_COMPLETED` | Refund settled | | `INCOMING_PAYMENT.REFUND_FAILED` | Refund failed | ### Example Payloads > [tab] PENDING > > ```json theme={null} > { > "type": "OUTGOING_PAYMENT.PENDING", > "data": { > "id": "Transaction:...", > "status": "PENDING", > "type": "OUTGOING", > "direction": "DEBIT", > "sentAmount": {"amount": 100000, "currency": {"code": "USD"}}, > "receivedAmount": {"amount": 92000, "currency": {"code": "EUR"}}, > "createdAt": "2025-10-03T15:00:00Z" > } > } > ``` > [tab] PENDING\_AUTHORIZATION > > ```json theme={null} > { > "type": "OUTGOING_PAYMENT.PENDING_AUTHORIZATION", > "data": { > "id": "Transaction:...", > "status": "PENDING_AUTHORIZATION", > "type": "OUTGOING", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "quoteId": "Quote:019542f5-b3e7-1d02-0000-000000000006", > "sentAmount": {"amount": 100000, "currency": {"code": "USD"}}, > "receivedAmount": {"amount": 92000, "currency": {"code": "EUR"}}, > "createdAt": "2025-10-03T15:00:00Z" > } > } > ``` > > The payload carries no `scaChallenge` — that field lives on the quote. Use `quoteId` to fetch `GET /quotes/{quoteId}`, read its `scaChallenge`, and authorize with `POST /quotes/{quoteId}/authorize`. > [tab] COMPLETED > > ```json theme={null} > { > "type": "OUTGOING_PAYMENT.COMPLETED", > "data": { > "id": "Transaction:...", > "status": "COMPLETED", > "settledAt": "2025-10-03T15:05:00Z" > } > } > ``` > [tab] FAILED > > ```json theme={null} > { > "type": "OUTGOING_PAYMENT.FAILED", > "data": { > "id": "Transaction:...", > "status": "FAILED", > "failureReason": "ACCOUNT_INVALID" > } > } > ``` > [tab] REFUND\_COMPLETED > > ```json theme={null} > { > "type": "OUTGOING_PAYMENT.REFUND_COMPLETED", > "data": { > "id": "Transaction:...", > "status": "FAILED", > "refund": { > "reference": "UMA-Q12345-REFUND", > "initiatedAt": "2025-10-03T15:10:00Z", > "settledAt": "2025-10-03T15:15:00Z", > "status": "COMPLETED", > "reason": "TRANSACTION_FAILED" > } > } > } > ``` ### Handling Webhooks ```javascript theme={null} app.post('/webhooks/grid', async (req, res) => { const { data, type } = req.body; switch (type) { case 'OUTGOING_PAYMENT.PENDING_AUTHORIZATION': { // EU customers only: the challenge is on the quote, not the transaction const quote = await getQuote(data.quoteId); await promptScaChallenge(data.customerId, data.quoteId, quote.scaChallenge); break; } case 'OUTGOING_PAYMENT.COMPLETED': await notifyCustomer(data.customerId, 'Payment delivered!'); break; case 'OUTGOING_PAYMENT.FAILED': await notifyCustomer(data.customerId, `Payment failed: ${data.failureReason}`); break; case 'OUTGOING_PAYMENT.REFUND_COMPLETED': await notifyCustomer(data.customerId, 'Refund completed.'); break; case 'OUTGOING_PAYMENT.REFUND_FAILED': await notifyCustomer(data.customerId, 'Refund failed. Contact support.'); break; } await updateTransactionStatus(data.id, type); res.status(200).json({ received: true }); }); ``` ### Scenarios > [tab] Happy Path > > The standard successful payment flow: > > 1. `OUTGOING_PAYMENT.PENDING` > 2. `OUTGOING_PAYMENT.PROCESSING` > 3. `OUTGOING_PAYMENT.COMPLETED` > [tab] SCA Authorization (EU) > > The customer must satisfy a Strong Customer Authentication challenge before the payout proceeds: > > 1. `OUTGOING_PAYMENT.PENDING` > > 2. `OUTGOING_PAYMENT.PENDING_AUTHORIZATION` > > 3. `OUTGOING_PAYMENT.PROCESSING` > > 4. `OUTGOING_PAYMENT.COMPLETED` > > > [!NOTE] > > > > A single payment can require more than one authorization in sequence, so `OUTGOING_PAYMENT.PENDING_AUTHORIZATION` may arrive several times before `PROCESSING`. Loop on the quote's status rather than assuming one authorization releases the payment. > > If the challenge expires before the customer satisfies it, the transaction lands on `OUTGOING_PAYMENT.FAILED` with `failureReason: SCA_NOT_COMPLETED` and no funds move. Create a new quote to try again. > [tab] Failure with Refund > > Payment fails and the refund succeeds: > > 1. `OUTGOING_PAYMENT.PENDING` > 2. `OUTGOING_PAYMENT.PROCESSING` > 3. `OUTGOING_PAYMENT.FAILED` > 4. `OUTGOING_PAYMENT.REFUND_PENDING` > 5. `OUTGOING_PAYMENT.REFUND_COMPLETED` > [tab] Failure with Failed Refund > > Payment fails and the refund also fails: > > 1. `OUTGOING_PAYMENT.PENDING` > > 2. `OUTGOING_PAYMENT.PROCESSING` > > 3. `OUTGOING_PAYMENT.FAILED` > > 4. `OUTGOING_PAYMENT.REFUND_PENDING` > > 5. `OUTGOING_PAYMENT.REFUND_FAILED` > > > [!WARNING] > > > > If a refund fails, [contact our team](https://www.lightspark.com/contact) to resolve the issue manually. > [tab] Bank Return > > Payment initially succeeds but the bank returns it: > > 1. `OUTGOING_PAYMENT.PENDING` > > 2. `OUTGOING_PAYMENT.PROCESSING` > > 3. `OUTGOING_PAYMENT.COMPLETED` > > 4. `OUTGOING_PAYMENT.FAILED` — `failureReason` is `PAYOUT_RETURNED`, and `destination.railDetails.status` is `RETURNED`, with `returnedAt` and the NACHA `returnCode` > > 5. `OUTGOING_PAYMENT.REFUND_PENDING` > > 6. `OUTGOING_PAYMENT.REFUND_COMPLETED` > > > [!NOTE] > > > > Bank returns can happen days after initial completion. Continue processing webhook events for transactions even after they reach `COMPLETED`. If the originating bank later dishonors the return, the payout stays `FAILED` and its refund stands; the returned funds are reconciled manually, and `OUTGOING_PAYMENT.FAILED` is sent again with `destination.railDetails.status` `RETURN_DISHONORED`. > [tab] Manual Cancellation > > Payment is cancelled while still processing, and the refund succeeds: > > 1. `OUTGOING_PAYMENT.PENDING` > > 2. `OUTGOING_PAYMENT.PROCESSING` > > 3. `OUTGOING_PAYMENT.REFUND_PENDING` > > 4. `OUTGOING_PAYMENT.REFUND_COMPLETED` > > 5. `OUTGOING_PAYMENT.COMPLETED` > > > [!NOTE] > > > > In the manual cancellation flow, `OUTGOING_PAYMENT.COMPLETED` fires after the refund settles. This indicates the payout provider ultimately delivered the original payment despite the cancellation attempt. Your system should check the transaction's `refund` object to determine whether the payment was refunded or delivered. > > To test this flow in sandbox, see [Sandbox Manual Cancellations](/api-reference/sandbox-manual-cancellations). ## Receipt Delivery Confirmation Some platforms are contractually required to send a receipt to their customer after a transaction completes. Use the receipt confirmation endpoint to record when the receipt was delivered: ```bash theme={null} curl -X POST https://api.lightspark.com/grid/2025-10-13/transactions/Transaction:019542f5-b3e7-1d02-0000-000000000030/confirm \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{"receiptDeliveryConfirmedAt": "2025-08-15T14:31:00Z"}' ``` **Response:** the updated transaction, with `receiptDeliveryConfirmedAt` populated: ```json theme={null} { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000030", "status": "COMPLETED", "receiptDeliveryConfirmedAt": "2025-08-15T14:31:00Z" } ``` If you omit `receiptDeliveryConfirmedAt` from the request body, Grid uses the current server time. Calling this endpoint again updates the stored confirmation time. > [!NOTE] > > This endpoint is only necessary if your platform agreement requires receipt confirmation. Most integrations do not need this step. ## Listing Transactions Query all transactions for a customer or date range: ```bash theme={null} GET /transactions?customerId=Customer:abc123&startDate=2025-10-01T00:00:00Z&limit=50 ``` **Response:** ```json theme={null} { "data": [ { "id": "Transaction:...", "status": "COMPLETED", "type": "OUTGOING", "direction": "DEBIT", "sentAmount": {"amount": 100000, "currency": {"code": "USD"}}, "receivedAmount": {"amount": 92000, "currency": {"code": "EUR"}}, "settledAt": "2025-10-03T15:05:00Z" } ], "hasMore": false, "nextCursor": null } ``` Use for reconciliation and reporting. ## Failure Handling ### Common Failure Reasons | Failure Reason | Description | Recovery | | - | - | - | | `QUOTE_EXPIRED` | Quote expired before execution | Create new quote | | `QUOTE_RATE_UNAVAILABLE` | Quoted exchange rate was refused at execution; nothing was exchanged | Create new quote | | `QUOTE_EXECUTION_FAILED` | Error executing the quote; a debited amount is refunded automatically | Create new quote | | `FUNDING_AMOUNT_MISMATCH` | Funding amount doesn't match expected amount | Verify amounts and retry | | `PAYOUT_RETURNED` | Receiving bank returned or reversed the payout | Verify details and retry | | `LIMIT_EXCEEDED` | Payout exceeds a partner limit | Reduce amount or [contact our team](https://www.lightspark.com/contact) | | `ACCOUNT_CANNOT_RECEIVE` | Recipient account can't accept the payment | Use a different account | | `ACCOUNT_INVALID` | Recipient account details are wrong or not found | Correct recipient details | | `COMPLIANCE_REJECTED` | Payout partner rejected on compliance grounds | [Contact our team](https://www.lightspark.com/contact) | The `failureReason` field on a transaction may return additional values, including deprecated reasons (`LIGHTNING_PAYMENT_FAILED`, `COUNTERPARTY_POST_TX_FAILED`) retained for historical transactions. When a transaction fails, a refund is initiated automatically. Track the refund via the `refund` object on the transaction and `OUTGOING_PAYMENT.REFUND_*` webhook events. See [Refund Object](#refund-object) above. ## Best Practices > [accordion] Store transaction IDs for reconciliation > > Save transaction IDs to your database: > > ```javascript theme={null} > const transaction = await executeQuote(quoteId); > await db.transactions.insert({ > gridTransactionId: transaction.id, > internalPaymentId: paymentId, > status: transaction.status, > createdAt: new Date() > }); > ``` > [accordion] Handle idempotency > > Use the `Idempotency-Key` HTTP header for safe retries: > > ```javascript theme={null} > const idempotencyKey = `payment-${userId}-${Date.now()}`; > await fetch('https://api.lightspark.com/grid/2025-10-13/quotes', { > method: 'POST', > headers: { > 'Idempotency-Key': idempotencyKey, > 'Content-Type': 'application/json', > 'Authorization': `Basic ${credentials}`, > }, > body: JSON.stringify(params), > }); > ``` > [accordion] Provide clear status messages to users > > Translate technical statuses to user-friendly messages: > > ```javascript theme={null} > function getUserMessage(webhookType, data) { > switch (webhookType) { > case 'OUTGOING_PAYMENT.PENDING': > return 'Payment processing...'; > case 'OUTGOING_PAYMENT.PENDING_AUTHORIZATION': > return 'Authorization required. Complete the security challenge to continue.'; > case 'OUTGOING_PAYMENT.PROCESSING': > return 'Payment in progress...'; > case 'OUTGOING_PAYMENT.COMPLETED': > return 'Payment delivered!'; > case 'OUTGOING_PAYMENT.FAILED': > return 'Payment failed. Please try again or contact support.'; > case 'OUTGOING_PAYMENT.REFUND_PENDING': > return 'Refund in progress...'; > case 'OUTGOING_PAYMENT.REFUND_COMPLETED': > return 'Refund completed. Funds returned to your account.'; > case 'OUTGOING_PAYMENT.REFUND_FAILED': > return 'Refund failed. Please contact support.'; > default: > return 'Payment status updated.'; > } > } > ``` # FAQ Source: https://agents-docs.lightspark.com/platform-overview/introduction/faq ## Getting Started > [accordion] What is Grid and who is it for? > > Grid is a low-level payment infrastructure API that enables businesses, financial institutions, and developers to send and receive payments globally across fiat currencies, stablecoins, and Bitcoin. > > Grid is ideal for: > > * Fintech companies building payment products > * Businesses sending international payouts > * Platforms enabling P2P payments > * Companies offering crypto on/off-ramps > * Businesses distributing rewards or incentives > [accordion] How do I get started with Grid? > > **1. Create an Account** > > Go to [app.lightspark.com](https://app.lightspark.com) to create your account and access the Grid dashboard. > > **2. Generate Sandbox API Keys** > > Generate your sandbox API keys from the dashboard and start building and testing your integration. > > **3. Go to Production** > > Once testing is complete, reach out to your Lightspark contact to receive production credentials and launch with real transactions. > [accordion] Do I need to know about cryptocurrency to use Grid? > > No. Grid handles all on-chain operations, wallet management, and crypto conversions behind the scenes. You interact with a simple REST API using familiar concepts like customers, accounts, and transactions. Bitcoin and stablecoins are just another payment rail that Grid manages for you automatically. ## Building your integration > [accordion] Can I test without moving real money? > > Yes. The sandbox lets you test your integration without making real payments. Use sandbox API credentials and the simulation tools to exercise payment outcomes before going live. See [sandbox testing](/api-reference/sandbox-testing). > [accordion] Do sandbox and production use different APIs? > > They use the same base URL. Your API token determines which environment a request uses. Production access is arranged with your Lightspark contact after testing. See [environments](/api-reference/environments). > [accordion] Which currencies and payment rails can I use? > > Check [currencies and payment rails](/platform-overview/core-concepts/currencies-and-rails) for coverage. Your platform's configuration determines which currencies are enabled for your integration. Use `GET /config` to check your supported currencies and their customer information requirements. > [accordion] How do I know whether a payment has completed? > > Use webhooks to receive payment status changes, and retrieve the transaction when you need its current state. Verify webhook signatures and handle duplicate events using the event ID. See the [transaction lifecycle](/platform-overview/core-concepts/transaction-lifecycle) and [webhook guide](/api-reference/webhooks). > [accordion] Can I start issuing cards as soon as I have API credentials? > > Cards must be enabled for your platform, and you need to complete card-issuer onboarding before launching. The [implementation overview](/cards/onboarding/implementation-overview) covers the setup and prerequisites. ## Licensing and customer verification > [accordion] Is Lightspark licensed? > > Yes. Lightspark's regulated entities hold licenses for financial services, including money transmission in the US and electronic money and crypto-asset services in Europe. See the [licenses page](https://www.lightspark.com/licenses) for the entities, jurisdictions, and licenses. Coverage depends on the service and where you operate; [contact our team](https://www.lightspark.com/contact) to discuss your setup. > [accordion] Who handles customer verification? > > It depends on your platform's setup. Lightspark provides hosted and API-based KYC/KYB flows for platforms that rely on Lightspark for verification. Regulated platforms follow a separate onboarding path. Requirements vary by customer type, region, and currency. See [customer configuration](/payouts-and-b2b/onboarding/configuring-customers) for both paths and the information to collect. # What is Grid? Source: https://agents-docs.lightspark.com/platform-overview/introduction/what-is-grid ![What is Grid hero](https://mintcdn.com/agents-docs/qL8kqlSYbvexR8cg/images/heroes/hero-what-is-grid-light.svg?fit=max\&auto=format\&n=qL8kqlSYbvexR8cg\&q=85\&s=fafa500fa08205adae1e5f47fab0167d) Grid is a low-level payment infrastructure API that enables modern financial institutions, businesses, or any developer to send, receive, and settle value globally across fiat currencies, stablecoins, and Bitcoin. With a single, simple API, you can build applications that move money instantly across borders without the complexity of orchestrating multiple payment rails or currencies. ## Features ![](/images/icons/code.svg) One API Move money anywhere. Fiat to fiat, crypto to fiat, or the other way around, all through a single API. ![](/images/icons/globe.svg) Global Coverage Send and receive across 65+ countries using local instant rails or global crypto rails. ![](/images/icons/lightning.svg) Real-Time Settlement Instant, 24/7/365. Powered by Bitcoin and local payment networks. ![](/images/icons/shield.svg) No Crypto Headaches We handle all the on-chain logic, wallet ops, and conversions so you don't have to. ## What to build Our APIs are intentionally low-level, giving developers, institutions, and businesses control over what they build. Fiat, stablecoins, or other digital assets. Developers use them for things like issuing cards, creating virtual accounts and wallets, collecting payments, and sending payouts. You might receive stablecoins and pay out pesos to a bank account, or let customers spend from an account with a card. The APIs work across assets and payment rails. We expose the primitives for moving money between accounts, wallets, and sources of liquidity around the world. You decide how to put them together. Have something else in mind? [Contact our team](https://www.lightspark.com/contact) to explore what's possible for your use case. ## Next steps ![](/images/icons/blocks.svg) Entities & Relationships Understand Grid's core data model and how entities relate to each other [Open](/platform-overview/core-concepts/entities) ![](/images/icons/arrow-left-right.svg) Quote System Learn how exchange rates, pricing, and payment execution work [Open](/platform-overview/core-concepts/quote-system) ![](/images/icons/bank.svg) Account Model Explore internal and external accounts and how they work together [Open](/platform-overview/core-concepts/account-model) ![](/images/icons/repeat.svg) Transaction Lifecycle Follow a payment from creation through settlement [Open](/platform-overview/core-concepts/transaction-lifecycle) ![](/images/icons/globe.svg) Currencies & Payment Rails View supported currencies, countries, and payment methods [Open](/platform-overview/core-concepts/currencies-and-rails) # Factor enrollment Source: https://agents-docs.lightspark.com/platform-overview/sca/factor-enrollment Enroll and manage a customer's TOTP and passkey factors > [!NOTE] > > Applies only to customers in an SCA-required region (EU). Every endpoint here > returns **`409`** for other customers. `SMS_OTP` needs no enrollment; a code is sent to the customer's verified phone. `TOTP` and `PASSKEY` must be enrolled before a customer can authenticate with them. Enrolled factors then appear in `scaChallenge.availableFactors` and can be requested per transaction (see [per-transaction authorization](/platform-overview/sca/per-transaction-authorization)). Enrollment is two calls, both discriminated by a `type` field (`TOTP` or `PASSKEY`): * `POST /sca/factors` — start enrollment; returns the factor-specific material. * `POST /sca/factors/confirm` — finish enrollment with the factor-specific proof. All paths below are relative to `https://api.lightspark.com/grid/2025-10-13`. ## Enroll a TOTP authenticator **1. Start enrollment** ```bash theme={null} POST /sca/factors?customerId={customerId} { "type": "TOTP" } ``` Returns the shared secret and an `otpauth://` provisioning URI. Render `totpUri` as a QR code (or show `secretBase32Encoded` for manual entry) so the customer can add it to their authenticator app. ```json theme={null} { "type": "TOTP", "secret": "…", "secretBase32Encoded": "ABC123…", "totpUri": "otpauth://totp/Grid:customer@example.com?secret=ABC123&issuer=Grid" } ``` **2. Confirm enrollment** Submit the `secret` from the start call plus the first code the app produces. Grid returns one-time **recovery codes**; surface them to the customer once and don't store them server-side. ```bash theme={null} POST /sca/factors/confirm?customerId={customerId} { "type": "TOTP", "secret": "…", "code": "000000" } ``` ```json theme={null} { "type": "TOTP", "recoveryCodes": ["ABCD-EFGH-IJKL", "MNOP-QRST-UVWX"] } ``` A wrong or expired code returns `400`. **In sandbox, the code is always `000000`.** ## Enroll a passkey Passkey enrollment is a standard WebAuthn registration ceremony. Grid issues the options, the customer's device produces the credential, and you hand it back. > [!NOTE] > > A customer may have **up to two passkeys**. With two enrolled, starting another > returns `409` (`PASSKEY_ALREADY_ENROLLED`). Delete one first (see below). To > replace a passkey, enroll the new one before deleting the old one. **1. Start enrollment** ```bash theme={null} POST /sca/factors?customerId={customerId} { "type": "PASSKEY" } ``` ```json theme={null} { "type": "PASSKEY", "options": { "…": "opaque WebAuthn PublicKeyCredentialCreationOptions" }, "allowedOrigins": ["https://app.example.com"], "relyingPartyId": "app.example.com" } ``` Pass `options` unmodified to the device's WebAuthn API (`navigator.credentials.create`). The ceremony must run against one of `allowedOrigins`. **2. Confirm enrollment** Submit the credential the device produced and the `origin` it ran against. ```bash theme={null} POST /sca/factors/confirm?customerId={customerId} { "type": "PASSKEY", "origin": "https://app.example.com", "credential": { "…": "opaque WebAuthn credential" } } ``` Returns the enrolled `factor` (an `ScaFactorView`, including the `credentialId` you'll use to delete it later). An invalid credential or origin returns `400`. ## List enrolled factors ```bash theme={null} GET /sca/factors?customerId={customerId} ``` ```json theme={null} { "factors": [ { "factor": "TOTP", "name": "Authenticator app" }, { "factor": "PASSKEY", "credentialId": "…", "name": "iPhone" } ] } ``` `credentialId` is populated only for `PASSKEY` factors. ## Delete a factor ```bash theme={null} DELETE /sca/factors/{credentialId}?customerId={customerId} ``` Returns `204`. Use the `credentialId` from the factor list (or the confirm response). Today only passkeys carry a `credentialId`, so this is how you remove an enrolled passkey. # Login & sessions Source: https://agents-docs.lightspark.com/platform-overview/sca/login-and-sessions The end-user SCA login and the session it grants > [!NOTE] > > Applies only to customers in an SCA-required region (EU). Every endpoint here > returns **`409`** for other customers. Per-transaction authorization gates individual debits. The **SCA login** is separate: it authenticates the end user to open a longer-lived session that covers reads and account access beyond the per-transaction window. Grid provides the login plumbing; your application decides when to drive it (for example, when a customer opens their account and the previous session has lapsed). > [!NOTE] > > **A customer's EUR / USDC accounts aren't provisioned until their first SCA > login after KYC approval.** Provisioning is deferred from KYC-approval time to > the first login that opens a valid SCA session, so a freshly KYC-approved > customer's EUR / USDC accounts won't appear in `GET /customers/internal-accounts` > until then. Expect those accounts to be unavailable, and drive the SCA login > once KYC is approved, before relying on them. All paths below are relative to `https://api.lightspark.com/grid/2025-10-13`. ## Logging in **1. Start the login** ```bash theme={null} POST /sca/login/start?customerId={customerId} { "factor": "SMS_OTP" } ``` The response carries only what the chosen factor needs: * **`SMS_OTP`**: a code is dispatched; you get back `challengeId` and `expiresAt`. * **`TOTP`**: nothing extra; the customer reads the code from their app. * **`PASSKEY`**: WebAuthn `passkeyOptions` (with `allowedOrigins` and `relyingPartyId`) to pass to the device. The factor must already be enrolled (or, for `SMS_OTP`, the phone verified). See [factor enrollment](/platform-overview/sca/factor-enrollment). **2. Complete the login** Submit the proof for the factor you started with: `code` for `SMS_OTP` / `TOTP` (echoing `challengeId` for `SMS_OTP`), or `passkeyAssertion` + `origin` for `PASSKEY`. Every completion also requires **`endUserIpAddress`** — the IP of the end user's device the login is performed from, recorded against the login event and fed into risk assessment. Supply the customer's address, not your server's. ```bash theme={null} POST /sca/login/complete?customerId={customerId} { "factor": "SMS_OTP", "challengeId": "…", "code": "000000", "endUserIpAddress": "203.0.113.42" } ``` ```json theme={null} { "status": "SUCCESS", "sessionExpiresAt": "2026-01-29T12:00:00Z" } ``` A `status` of `SUCCESS` opens the session and revokes any previous SCA session for that customer, and `sessionExpiresAt` gives its absolute expiry — prompt a re-login ahead of it rather than waiting for a call to fail. Any other `status` value means the login did not complete; treat only `SUCCESS` as success. An invalid or expired proof — or a missing or malformed `endUserIpAddress` — returns `400`. **In sandbox, the code is always `000000`.** ## Session scope and fresh authentication An active SCA login session covers EUR / USDC account reads for 180 days; the login-complete response's `sessionExpiresAt` gives the exact timestamp it lapses. A request for transaction history older than 90 days requires fresh SCA, even when the broader session has not expired. Money movement in SCA-regulated currencies is refused with **`409` `SCA_SESSION_REQUIRED`** once the session passes, so track `sessionExpiresAt` and drive a re-login before it does. When Grid indicates that a session is missing, expired, or too old for the requested history, restart the login flow before retrying the read. ## Account-security signals Grid runs an adaptive-authentication risk engine that maintains each customer's login-security state. Because your application owns the customer's login, you report the security-relevant events it sees so the engine can act on them: ```bash theme={null} POST /sca/record-event?customerId={customerId} { "eventType": "FAILED_LOGIN_ATTEMPT", "endUserIpAddress": "203.0.113.42" } ``` Returns the customer's resulting login-security state so you can surface a lockout — `{ eventType, suspended, lockedUntil, failedAttempts }`. When the customer is locked out, this endpoint, `POST /sca/login/complete`, and [quote authorization](/platform-overview/sca/per-transaction-authorization) return `423` with `details.lockedUntil` (when they may retry) and `details.failedAttempts`. `eventType` must be one of: | `eventType` | Effect | | - | - | | `FAILED_LOGIN_ATTEMPT` | Increments the failed-login counter and escalates a lockout: **5 → 15 min, 6 → 30 min, 7 → 1 hour, 8 → 24 hours, 9 or more → suspension.** | | `RESET_PASSWORD_COMPLETED` | Revokes every active SCA session for the customer and clears the failed-login counter. | Report `FAILED_LOGIN_ATTEMPT` on each failed sign-in and `RESET_PASSWORD_COMPLETED` once a password recovery finishes. Any other value returns `400`. > [!NOTE] > > The failed-login counter is cumulative and is **not** reset by a successful > login; only `RESET_PASSWORD_COMPLETED` clears it. Record that event after a > password recovery to zero the counter and clear a time-bounded lockout, rather > than relying on the customer simply logging in again. A suspended customer > (9 or more failed attempts, locked with no automatic expiry) clears the > suspension the same way — a completed password reset. ## Your responsibilities Grid provides the SCA endpoints and risk decisions; your application owns the end-user login and session experience. Report every failed sign-in and completed password recovery through `record-event`, enforce any returned lockout before offering another login attempt, and do not store or reuse a customer's TOTP secret or passkey material. Treat TOTP secrets and WebAuthn ceremony data as end-user credentials, not platform credentials. # Strong Customer Authentication Source: https://agents-docs.lightspark.com/platform-overview/sca/overview How Grid satisfies PSD2 SCA for EU customers > [!NOTE] > > **This applies only to customers in a region where Strong Customer Authentication > is required, in practice customers in the EU (EUR / USDC).** For every other > customer none of this appears: money-movement calls complete as usual, no > `scaChallenge` is returned, the authentication endpoints return `409`, and you > can skip this section. Under PSD2, EU e-money and e-money-token (EUR / USDC) money movement must be confirmed by the end user with Strong Customer Authentication (SCA). Grid wraps SCA so you satisfy it through the same resources you already use. There is no separate product to integrate, and the same request shapes work for every customer whether or not SCA applies. This section covers the whole surface: > [card] [Per-transaction authorization](/platform-overview/sca/per-transaction-authorization) > > Authorize a money movement that came back `PENDING_AUTHORIZATION`. This is the flow you hit most often. > [card] [Factor enrollment](/platform-overview/sca/factor-enrollment) > > Enroll and manage a customer's TOTP and passkey factors. > [card] [Login & sessions](/platform-overview/sca/login-and-sessions) > > The end-user SCA login and the session it grants for reads. > [card] [Trusted beneficiaries](/platform-overview/sca/trusted-beneficiaries) > > Whitelist a payee once so future sends to it skip the per-transaction ceremony. > [card] [Two-factor reset](/platform-overview/sca/two-factor-reset) > > Recover a customer who has lost their factors, gated by an identity (liveness) check. ## What SCA covers SCA gates **debits** on EU-regulated balances. EUR / USDC reads are covered by an active [SCA login session](/platform-overview/sca/login-and-sessions), while non-EUR/USDC accounts do not require SCA. "Dynamic linking" means the authorization is cryptographically bound to the transaction's amount and payee (PSD2 Article 97(2)); it forces a fresh, transaction-specific challenge, and it's the reason some flows can't use TOTP (see [Authentication factors](#authentication-factors)). | Operation | SCA required? | Dynamically linked? | | - | - | - | | Send EUR / USDC (SEPA + intra-ledger) | Yes | Yes | | Convert **from** EUR / USDC (the swap leg) | Yes | Yes | | On-chain / Lightning withdrawal | Yes | No | | Trust / untrust a beneficiary | Yes | No | | Send to an **already-trusted** beneficiary | Lighter, no dynamic linking | No | | Reading balances / history | Covered by the login session | n/a | | Non-EUR/USDC accounts (e.g. USD) | No | n/a | ## Authentication factors The `scaChallenge.availableFactors` field tells you which factors a customer may use; `scaChallenge.factor` is the one in use (default `SMS_OTP`). | Factor | Enrollment | Per-transaction debit | | - | - | - | | `SMS_OTP` | None; a code is sent to the customer's verified phone | ✅ Default | | `PASSKEY` | Required (WebAuthn credential) | ✅ | | `TOTP` | Required (authenticator app) | Available only where the challenge is not dynamically linked; never assume it is available for a particular debit | TOTP is barred from dynamically-linked debits because an authenticator code is derived only from a clock and a shared secret, so it can't be bound to *this* amount and payee. It stays valid for flows that don't require dynamic linking: login, trusting a beneficiary, sends to an already-trusted beneficiary, and eligible on-chain or Lightning withdrawals. Always use the factors in `scaChallenge.availableFactors`; that field, not the operation name, is the source of truth for the challenge Grid issued. ## The authorization flow For an SCA-required customer, a money-movement call that would otherwise complete instead returns the quote in status **`PENDING_AUTHORIZATION`** carrying an **`scaChallenge`**, and the transfer is not released until the challenge is satisfied. A single money movement can require **more than one** challenge in sequence, so loop on status rather than assuming one authorization releases the transfer. ```mermaid theme={null} sequenceDiagram participant P as Your platform participant G as Grid participant U as End user P->>G: Initiate money movement (execute a quote) G-->>P: PENDING_AUTHORIZATION + scaChallenge G->>U: Deliver challenge (e.g. SMS OTP) loop while status == PENDING_AUTHORIZATION U-->>P: Provide proof (code / passkey assertion) P->>G: POST /quotes/{quoteId}/authorize (proof) G-->>P: Updated quote + next scaChallenge (if any) end G-->>P: Quote leaves PENDING_AUTHORIZATION, transfer released ``` [Per-transaction authorization](/platform-overview/sca/per-transaction-authorization) covers the mechanics: authorizing the quote, the multi-step loop, resending an expired code, and the realtime-funding-quote nuance. ## Lifetimes & limits | Aspect | Behavior | | - | - | | Challenge expiry | Each challenge carries an absolute `scaChallenge.expiresAt` (UTC). SMS codes expire after 5 minutes; TOTP and passkey challenges expire after 10 minutes. After expiry, the challenge can no longer be authorized — if it lapses unsatisfied, the transaction fails with `failureReason: SCA_NOT_COMPLETED` and no funds move; create a new quote and authorize while the new challenge is live. | | Resend | `SMS_OTP` only. Resending reuses the existing challenge and does not extend `expiresAt`; `PASSKEY` (and `TOTP`) codes can't be resent. | | Repeated failures | A challenge permits at most 5 attempts. Too many failed authorizations may invalidate it and return `429 RATE_LIMITED`, so honor `Retry-After`. | | Login session | A completed [SCA login](/platform-overview/sca/login-and-sessions) grants a 180-day session whose exact expiry is returned as `sessionExpiresAt`. EUR / USDC history older than 90 days requires fresh SCA even during that session, and money movement is refused with `SCA_SESSION_REQUIRED` once it passes — prompt a re-login ahead of `sessionExpiresAt`. | | Account lockout | Repeated `FAILED_LOGIN_ATTEMPT` signals escalate a lockout (5 → 15 min, 6 → 30 min, 7 → 1 hour, 8 → 24 hours, 9+ → suspension). See [account-security signals](/platform-overview/sca/login-and-sessions#account-security-signals). | | 2FA reset window | A started reset carries its own `expiresAt`; complete it before then. | ## Errors you'll encounter | Status | Meaning | What to do | | - | - | - | | `400` | Invalid or expired proof: wrong code, expired challenge, or a factor that can't satisfy this challenge (e.g. `TOTP` on a dynamically-linked debit). | Re-collect the proof. If the code lapsed, resend (`SMS_OTP`) or start over. | | `409` | SCA isn't required for this customer (non-EU), there's no pending challenge, or the factor's code can't be resent (e.g. `PASSKEY`). | Don't retry the same call. Treat a non-EU `409` as nothing to authorize. | | `409` `SCA_SESSION_REQUIRED` | The customer's SCA login session is missing or expired, so the money movement or authorization is refused. | Complete a fresh [SCA login](/platform-overview/sca/login-and-sessions), then retry the call. | | `423` `ACCOUNT_LOCKED` | The customer is locked out after repeated failed attempts. | Wait out `details.lockedUntil`; see [account-security signals](/platform-overview/sca/login-and-sessions#account-security-signals). | | `429` | `RATE_LIMITED`: too many attempts or resends, and the challenge may now be invalidated. | Honor `Retry-After`; you may need to restart the flow. | | `404` | The customer, transaction, quote, external account, or reset wasn't found. | Check the id. | A challenge left unsatisfied until it expires fails the transaction with `failureReason: SCA_NOT_COMPLETED` rather than an HTTP error — no funds moved, so create a new quote and have the customer authorize while the challenge is live. ## Calling a customer outside SCA-regulated regions Every authentication endpoint returns **`409`** for customers outside SCA-regulated regions (non-EU), and no `scaChallenge` is ever attached to their transactions. You don't need to branch on region. Handle `scaChallenge` when it's present and treat its absence as nothing to do. # Per-transaction authorization Source: https://agents-docs.lightspark.com/platform-overview/sca/per-transaction-authorization Authorize an SCA-gated money movement This is the flow you hit most often: an SCA-required customer initiates a money movement, the quote comes back `PENDING_AUTHORIZATION` carrying an `scaChallenge`, and you authorize it before the transfer is released. For where this sits in the wider SCA surface, see the [overview](/platform-overview/sca/overview). > [!NOTE] > > **This applies only to customers in a region where Strong Customer Authentication > is required, in practice customers in the EU (EUR / USDC).** For every other > customer, none of this appears: money-movement calls complete as usual, no > `scaChallenge` is returned, and the authorization endpoints are not used. If you > don't serve EU customers you can skip this section. Under PSD2, EU e-money and e-money-token (EUR / USDC) money movement must be confirmed by the end user with Strong Customer Authentication (SCA). Grid wraps SCA so you satisfy it through the same resources you already use. There is no separate SCA product to integrate. ### When you'll encounter it For an SCA-required customer, a money-movement call that would otherwise complete instead returns the **quote** in status **`PENDING_AUTHORIZATION`** carrying an **`scaChallenge`** object, and the transfer is **not** released until the challenge is satisfied. The resulting transaction's webhooks also report `PENDING_AUTHORIZATION`, but the challenge lives on the quote — fetch it with `GET /quotes/{quoteId}` using the transaction's `quoteId`. This affects debits such as: * Sending EUR / USDC (SEPA and intra-ledger transfers) * Cross-currency conversions from EUR / USDC (the swap leg) * On-chain and Lightning withdrawals EUR / USDC reads are covered by an active SCA login session; non-EUR/USDC accounts do not require SCA. ### Authentication factors The `scaChallenge.availableFactors` field tells you which factors the customer may use. `scaChallenge.factor` is the one in use (default `SMS_OTP`). | Factor | Enrollment | Per-transaction debit | | - | - | - | | `SMS_OTP` | None; a code is sent to the customer's verified phone | ✅ Default | | `PASSKEY` | Required (WebAuthn credential) | ✅ | | `TOTP` | Required (authenticator app) | Available only where the challenge is not dynamically linked | TOTP cannot satisfy a dynamically linked debit because its code cannot be bound to the amount and payee. For a non-dynamically-linked challenge, use TOTP only when it appears in `scaChallenge.availableFactors`; that field is authoritative. Request a specific factor per transaction with the optional top-level `scaFactor` field on `execute` (`SMS_OTP` default, or `PASSKEY`). ### Satisfying a challenge Submit an `ScaAuthorization` proof to `POST /quotes/{quoteId}/authorize` for the quote that carries the challenge. Provide exactly one of `code` (for `SMS_OTP`) or `passkeyAssertion` + `origin` (for `PASSKEY`): ```bash theme={null} curl -X POST https://api.lightspark.com/grid/2025-10-13/quotes/{quoteId}/authorize \ -u "$GRID_API_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "code": "000000", "endUserIpAddress": "203.0.113.42" }' ``` The optional `endUserIpAddress` is the IP of the end user's device authorizing the operation; it feeds the SCA provider's risk assessment, so supply the customer's address, not your server's. Authorizing requires an active [SCA login session](/platform-overview/sca/login-and-sessions): if it has lapsed, the call returns `409` `SCA_SESSION_REQUIRED` — complete a fresh login, then authorize again. > [!WARNING] > > **Write your client to loop on status, not on a fixed challenge count.** Treat > `scaChallenge` as the challenge to satisfy *now*, not necessarily the only one: > after authorizing, re-inspect the returned quote, and if it is still > `PENDING_AUTHORIZATION` it carries the **next** `scaChallenge` (a new `id`) — > authorize that one too and repeat until it leaves `PENDING_AUTHORIZATION`. Once the quote is in `PENDING_AUTHORIZATION`, authorize it: `POST /quotes/{quoteId}/authorize`. This is the single authorize path for both `execute` (pre-funded) and realtime-funding quotes. The challenge — and the SMS code or passkey assertion that satisfies it — only exists after the challenge is issued, so the proof is always supplied on this follow-up call, never on the originating request. > [!NOTE] > > For a **realtime-funding quote**, the `202` / `PENDING_AUTHORIZATION` response > **withholds `paymentInstructions`** until the challenge is authorized. Authorize > first, then read `paymentInstructions` from the returned (advanced) quote. If you > read them off the initial pending response you'll show the customer nothing to > fund. If an SMS code lapses before it's used, re-send it. The existing challenge is reused, and its `expiresAt` is not extended. Use the quote resend endpoint: `POST /quotes/{quoteId}/authorize/resend`. ```bash theme={null} curl -X POST https://api.lightspark.com/grid/2025-10-13/quotes/{quoteId}/authorize/resend \ -u "$GRID_API_TOKEN" ``` If the challenge expires unsatisfied, the transaction fails with `failureReason: SCA_NOT_COMPLETED` and no funds move. Create a new quote and have the customer authorize while the new challenge is live. > [!NOTE] > > In **sandbox**, the SMS code is always `000000`. ### Reducing prompts for repeat payees Trusting a beneficiary (a one-time SCA-gated whitelisting step) lets subsequent sends to that payee skip the per-transaction challenge. Use this for recurring payouts to known destinations rather than authorizing every send. ### Calling a customer outside SCA-regulated regions The authorization endpoints return **`409`** for customers outside SCA-regulated regions (non-EU), and no `scaChallenge` is ever attached to their transactions. You don't need to branch on region. Handle `scaChallenge` when it's present and treat its absence as nothing to do. ## Walkthrough by flow The mechanics above are the same everywhere: inspect for an `scaChallenge`, submit an `ScaAuthorization`, and repeat until the resource leaves `PENDING_AUTHORIZATION`. What differs between flows is *which* call first returns the challenge and *which* resource you authorize. Here is each one end to end. > [tab] Send a payment (execute a quote) > > The common case — lock a quote, execute it, authorize the quote. > > **1. Lock a quote** > > `POST /quotes` returns a quote as usual. A standard (prefunded) send carries no > challenge at quote time. > > **2. Execute the quote** > > `POST /quotes/{quoteId}/execute` returns the **quote** in `PENDING_AUTHORIZATION` > with an `scaChallenge`. > > **3. Authorize the quote** > > `POST /quotes/{quoteId}/authorize` with the proof. Re-inspect the returned quote: > if it remains `PENDING_AUTHORIZATION`, authorize its next `scaChallenge`. Do not > assume a fixed number of challenges for a cross-currency or other multi-step > send. > [tab] Realtime-funding quote > > Here the challenge is issued at *quote* time and `paymentInstructions` are > withheld until you clear it — you authorize before you fund. > > **1. Request the quote** > > `POST /quotes` for a realtime-funded send returns `202` / > `PENDING_AUTHORIZATION` with an `scaChallenge`. `paymentInstructions` are > **omitted** from this response. > > **2. Authorize the quote** > > `POST /quotes/{quoteId}/authorize` with the proof — the challenge is carried by > the quote. Re-inspect the returned quote and continue the authorization loop if > it remains `PENDING_AUTHORIZATION`. > > **3. Read the payment instructions** > > Read `paymentInstructions` from the returned (advanced) quote and fund the > transfer. Reading them off the initial pending response would show the customer > nothing to fund. > [tab] Transfer-out (deprecated) > > `POST /transfer-out` is **deprecated** — create a quote with an internal account > source, an external account destination, and `immediatelyExecute: true` for the > same single-request send, then follow the quote + `execute` flow above (see the > [quote system](/platform-overview/core-concepts/quote-system) guide). The legacy > endpoint has no associated quote, and per-transaction SCA is authorized only on > the quote resource, so SCA-gated EU debits are not offered on it. For customers > outside SCA-regulated regions it continues to work until removal. # Trusted beneficiaries Source: https://agents-docs.lightspark.com/platform-overview/sca/trusted-beneficiaries Whitelist a payee so future sends skip the per-transaction ceremony > [!NOTE] > > Applies only to customers in an SCA-required region (EU). Every endpoint here > returns **`409`** for other customers. Trusting a beneficiary is a one-time, SCA-gated step that whitelists an external account. Only **USDC addresses** can be trusted today. Once trusted, future sends to that payee are no longer dynamically linked; they drop to a lighter authentication instead of a full per-transaction challenge. Use it for recurring payouts to known destinations. The beneficiary is identified end-to-end by its **`externalAccountId`** in the path, so there is no separate whitelist handle to track. All paths below are relative to `https://api.lightspark.com/grid/2025-10-13`. ## Trusting a beneficiary **1. Start the trust** ```bash theme={null} POST /customers/external-accounts/{externalAccountId}/trust ``` Returns the `scaChallenge` to satisfy: ```json theme={null} { "scaChallenge": { "id": "…", "factor": "SMS_OTP", "expiresAt": "2025-10-03T12:05:00Z", "availableFactors": ["SMS_OTP"] } } ``` > [!NOTE] > > `scaChallenge` may be **omitted** when no challenge is issued. In that case, > confirm directly without a `challengeId`. **2. Confirm the trust** Submit the proof for the factor Grid returned in `scaChallenge`: `code` for `SMS_OTP` / `TOTP`, or `passkeyAssertion` + `origin` for `PASSKEY`. Echo `challengeId` when start issued one. Do not assume a factor is available unless it appears in `scaChallenge.availableFactors`. ```bash theme={null} POST /customers/external-accounts/{externalAccountId}/trust/confirm { "challengeId": "…", "code": "000000" } ``` ```json theme={null} { "trusted": true } ``` An invalid or expired proof returns `400`. **In sandbox, the code is always `000000`.** ## Untrusting a beneficiary Untrusting mirrors trusting: a start call issues the challenge, then confirm submits the proof. **1. Start the untrust** ```bash theme={null} POST /customers/external-accounts/{externalAccountId}/untrust ``` Returns the `scaChallenge` to satisfy, omitted when no challenge is issued (the caller then confirms without a `challengeId`). **2. Confirm the untrust** Submit the proof for the factor Grid returned in `scaChallenge`: `code` for `SMS_OTP` / `TOTP`, or `passkeyAssertion` + `origin` for `PASSKEY`. Echo `challengeId` when start issued one. Returns `trusted: false`. ```bash theme={null} POST /customers/external-accounts/{externalAccountId}/untrust/confirm { "challengeId": "…", "code": "000000" } ``` Once untrusted, sends to that beneficiary are dynamically linked again and each one requires a full per-transaction challenge. ## Deleting a trusted account A trusted external account can't be deleted while the trust is in place: `DELETE /customers/external-accounts/{externalAccountId}` returns **`409` `BENEFICIARY_TRUSTED`**. Untrust first (the SCA-gated flow above), then delete. Trust is never revoked as a side effect of a delete — untrusting requires the customer's SCA challenge, which a `DELETE` has no way to carry. # Two-factor reset Source: https://agents-docs.lightspark.com/platform-overview/sca/two-factor-reset Recover a customer who has lost their SCA factor > [!NOTE] > > Applies only to customers in an SCA-required region (EU). Every endpoint here > returns **`409`** for other customers. When a customer loses an enrolled factor (a new phone, a deleted authenticator), they recover it with a **2FA reset**: an identity (liveness) check that, once passed, clears the lost factor so they can re-enroll it. It's a poll-based flow: start, poll until liveness passes, then complete. All paths below are relative to `https://api.lightspark.com/grid/2025-10-13`. **1. Start the reset** ```bash theme={null} POST /sca/factors/reset?customerId={customerId} { "factor": "TOTP", "endUserIpAddress": "203.0.113.42" } ``` Returns **`201`** with a `resetId` and opaque liveness handles. Embed `livenessAccessToken` in the verification SDK, or send the customer to `verificationLink`. `expiresAt` bounds the reset window. Reset initiation is rate-limited to **5 per 24 hours** per customer; beyond that this returns `429`. ```json theme={null} { "resetId": "…", "livenessAccessToken": "…", "verificationLink": "https://…", "expiresAt": "2025-10-03T12:30:00Z" } ``` **2. Poll until liveness passes** ```bash theme={null} GET /sca/factors/reset/{resetId}?customerId={customerId} ``` ```json theme={null} { "status": "INITIATED" } ``` Poll with a short backoff. `status` is one of `INITIATED`, `PENDING_REVIEW`, `LIVENESS_PASSED`, `COMPLETED`, `REJECTED`, or `EXPIRED`: * `INITIATED` — reset started, liveness not yet submitted; keep polling. * `PENDING_REVIEW` — liveness submitted and under review; keep polling. * `LIVENESS_PASSED` — proceed to complete. * `COMPLETED` (reset finished, factor cleared), `REJECTED` (liveness failed), and `EXPIRED` (window closed) are **terminal** — stop polling. On `REJECTED` or `EXPIRED`, start a new reset. The response also carries `factor`, `enrollmentStatus` (`PENDING` until the replacement factor is re-enrolled, then `COMPLETED`; `null` for an `SMS_OTP` reset), `expiresAt` (the window bound), and `completedAt`. Stop at any terminal status or once `expiresAt` passes; never poll indefinitely. **3. Complete the reset** ```bash theme={null} POST /sca/factors/reset/{resetId}/complete?customerId={customerId} { "endUserIpAddress": "203.0.113.42", "mobile": { "countryCode": "+1", "number": "4155550123" } } ``` Returns `204` and clears the lost factor. The body is required and must carry `endUserIpAddress`. For an `SMS_OTP` reset, also include the new `mobile` number — it's enrolled as the customer completes the reset; other factors need no `mobile`. Calling it before liveness has passed returns `400`. The customer can then re-enroll via [factor enrollment](/platform-overview/sca/factor-enrollment). # Use Cases Source: https://agents-docs.lightspark.com/platform-overview/use-cases Discover what you can build with Grid ## Global Accounts ![Financial apps and wallets](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/use-cases/gga-financial-apps-wallets.webp?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=66b10caacfdcfb8b42846f22d24d1c80) Financial apps and wallets Create dollar accounts users can hold, send from, and withdraw locally [Open](/global-accounts) ![Creator platforms](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/use-cases/gga-creator-platforms.webp?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=aa61e9228075c1eac4bd0d5f2a933777) Creator platforms Give creators a global dollar account for payouts and audience earnings [Open](/global-accounts) ![Marketplaces](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/use-cases/gga-marketplaces.webp?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=9ac5e6d2554b8433ee37e9e0e7a648b2) Marketplaces Let sellers collect marketplace earnings and withdraw through local rails [Open](/global-accounts) ![On-demand platforms](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/use-cases/gga-on-demand-platforms.webp?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=b20d967e97eb1e04db65cb243ba44d91) On-demand platforms Pay gig workers into dollar accounts they can withdraw from locally [Open](/global-accounts) ![Messaging platforms](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/use-cases/gga-messaging-platforms.webp?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=30d2c45fdd01ead9d05e4b4d60a5fccc) Messaging platforms Add dollar accounts for transfers inside chat and community experiences [Open](/global-accounts) ![Social platforms](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/use-cases/gga-social-platforms.webp?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=0bef80139c0591f8d59b6f49608b4031) Social platforms Help creators and communities receive, hold, and move dollar balances globally [Open](/global-accounts) ## Payouts & B2B ![Creator payouts](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/use-cases/payouts-b2b-creators.jpg?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=158a79de86c6d5b4642a2ca74191a182) Creator payouts Pay creators and influencers instantly, anywhere they bank [Open](/payouts-and-b2b) ![Payroll & contractors](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/use-cases/payouts-b2b-payroll.jpg?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=2797a2ea7ade4eca5fda9638a70500d1) Payroll & contractors Send salaries and contractor payments globally with locked FX rates [Open](/payouts-and-b2b) ![Marketplace disbursements](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/use-cases/payouts-b2b-marketplace.jpg?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=461c1d9116c9c17f97602ebee2584362) Marketplace disbursements Distribute earnings to sellers and service providers worldwide automatically [Open](/payouts-and-b2b) ![Supplier/vendor payments](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/use-cases/payouts-b2b-suppliers.jpg?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=65d5db419588ce62b374fea17f22a55d) Supplier/vendor payments Pay international vendors and suppliers in their local currency, settled in seconds [Open](/payouts-and-b2b) ## Ramps ![Buy BTC](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/use-cases/ramps-buy-btc.jpg?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=d434f6b64fb244fa87fc3eacea87d38a) Buy BTC Let users purchase Bitcoin directly inside your app [Open](/ramps) ![Sell BTC](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/use-cases/ramps-sell-btc.jpg?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=1de9923e67b0b30a700a7348992e4aee) Sell BTC Convert Bitcoin to fiat and settle to any bank account [Open](/ramps) ![Wallet funding](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/use-cases/ramps-on-ramp-usdc.jpg?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=45b17e1faad67f2e7fa23c0b2e1aa170) Wallet funding On-ramp fiat to fund user wallets with stablecoins or BTC [Open](/ramps) ![Fiat settlement](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/use-cases/ramps-offramp-usdc.jpg?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=84f7675ca0a99dbc2776965033a6b9be) Fiat settlement Off-ramp digital assets to local bank rails in real time [Open](/ramps) ## Global P2P ![Cross-border remittances](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/use-cases/global-p2p-remittances.jpg?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=75e59f237297bc3a8a543eb5bcc3c1d6) Cross-border remittances Move money across countries on the fastest, lowest-cost rails [Open](/global-p2p) ![Wallet-to-wallet (UMA)](https://mintcdn.com/agents-docs/YKCmEU4MWMlPER9B/images/use-cases/global-p2p-uma.jpg?fit=max\&auto=format\&n=YKCmEU4MWMlPER9B\&q=85\&s=77bc8e2b9eb917f0ff7f6309a56b1855) Wallet-to-wallet (UMA) Send and receive via UMA addresses across any participating app [Open](/global-p2p) # Depositing Funds Source: https://agents-docs.lightspark.com/ramps/accounts/depositing-funds Depositing funds into internal accounts Grid provides two options to fund an account: * Prefund * Just-in-time funding With prefunding, you'll deposit funds into internal accounts via Wire, PIX, hosted payment page, or crypto transfers. You can then use the balances as the source of funds for quotes and transfers. With just-in-time funding, you'll receive payment instructions as part of the quote. Once funds arrive, the payment to the receiver is automatically initiated. > [!NOTE] > > Just-in-time funding supports instant payment rails only (for example: RTP, > PIX, SEPA Instant). ## Prerequisites * You have created a customer (for customer-scoped internal accounts) * You have `GRID_CLIENT_ID` and `GRID_CLIENT_SECRET` > [!TIP] > > Export your credentials for use with cURL: > > ```bash theme={null} > export GRID_CLIENT_ID="your_client_id" > export GRID_CLIENT_SECRET="your_client_secret" > ``` ## Prefunding an account via push payments (Wire, SEPA, PIX, etc.) When customers need to deposit funds themselves, display the funding payment instructions in your application: **1. Retrieve the internal account** Fetch the customer's internal account for their desired currency using the API. ```bash cURL (Customer accounts) theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ```bash cURL (Platform internal accounts) theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/platform/internal-accounts' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **2. Extract payment instructions** Parse the `fundingPaymentInstructions` array and select the appropriate instructions based on your customer's preferred payment method. ```javascript theme={null} const instructions = account.fundingPaymentInstructions[0]; const bankInfo = instructions.accountOrWalletInfo; ``` **3. Display instructions clearly** Show the payment details prominently in your UI and enable copy / paste: * Account holder name * Bank name and routing information (account/routing number, CLABE, PIX key, etc.) * Payment URL (for COP deposits, redirect the customer to the `paymentUrl` returned in the payment instructions) * Reference code (if provided) * Any additional notes from `instructionsNotes` **Step 4** Customer initiates a push payment from their bank or wallet to the account/address specified. **5. Monitor for deposits** Set up webhook listeners to receive updates for the deposit transaction and account balance updates. The account balance will update automatically. > [!NOTE] > > You'll receive `INTERNAL_ACCOUNT.BALANCE_UPDATED` webhook events when the internal account balance changes. ## Just-in-time funding (payment instructions from a quote) With just-in-time funding, you request a quote and receive payment instructions (for example, a bank account or instant rail details). When your customer confirms the transaction, you trigger payment from your app. More details of just-in-time funding can be found in the Sending Payments guides. # External Accounts Source: https://agents-docs.lightspark.com/ramps/accounts/external-accounts Configure external bank accounts and crypto wallets for ramp destinations External accounts are bank accounts, cryptocurrency wallets, or payment destinations outside Grid where you can send funds. Grid supports two types: * **Customer external accounts** - Scoped to individual customers, used for withdrawals and customer-specific payouts * **Platform external accounts** - Scoped to your platform, used for platform-wide operations like receiving funds from external sources > [!NOTE] > > Customer external accounts often require some basic beneficiary information for compliance. > Platform accounts are managed at the organization level. ## Look up bank names with the Discoveries API Some countries require a `bankName` when creating an external account — including the Philippines, Nigeria, South Africa, and others. The `bankName` value must match exactly what Grid expects. Use the [Discoveries API](/api-reference/discoveries/list-available-receiving-institution-names) to retrieve the list of valid bank names for a given country and currency: ```bash cURL theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/discoveries?country=PH¤cy=PHP' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ```json Response theme={null} { "data": [ { "bankName": "BDO Unibank", "displayName": "BDO Unibank", "country": "PH", "currency": "PHP" }, { "bankName": "BPI", "displayName": "Bank of the Philippine Islands", "country": "PH", "currency": "PHP" } ] } ``` Use `bankName` from the response as the `bankName` value when creating an external account. The `displayName` is a human-friendly label you can show to your users. You can filter the list on the client side as the user types to provide a bank search experience. ## Create external accounts by region or wallet > [tab] United States > > **ACH, Wire, RTP** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USD", > "platformAccountId": "user_123_primary_bank", > "accountInfo": { > "accountType": "USD_ACCOUNT", > "accountNumber": "123456789", > "routingNumber": "021000021", > "bankAccountType": "CHECKING", > "bankName": "Chase Bank", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "John Doe", > "birthDate": "1990-01-15", > "nationality": "US", > "address": { > "line1": "123 Main Street", > "city": "San Francisco", > "state": "CA", > "postalCode": "94105", > "country": "US" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankAccountType` is required and must be `CHECKING` or `SAVINGS`. Grid uses it to set the > > ACH transaction code, so a value that does not match the account causes the receiving bank > > to return a notification of change. `routingNumber` must be 9 digits. > > > [!NOTE] > > > > Three further fields apply to the `WIRE` rail and are ignored on ACH, RTP and FedNow: > > `intermediaryBankName` and `intermediaryRoutingNumber`, for a beneficiary bank reachable only > > through a correspondent, and `fiToFiInformation` for bank-to-bank instructions. > [tab] Mexico > > **CLABE/SPEI** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "MXN", > "platformAccountId": "mx_beneficiary_001", > "accountInfo": { > "accountType": "MXN_ACCOUNT", > "clabeNumber": "123456789012345678", > "bankName": "BBVA Mexico", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "María García", > "birthDate": "1985-03-15", > "nationality": "MX", > "address": { > "line1": "Av. Reforma 123", > "city": "Ciudad de México", > "state": "CDMX", > "postalCode": "06600", > "country": "MX" > } > } > } > }' > ``` > [tab] Brazil > > **PIX** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "BRL", > "platformAccountId": "br_pix_001", > "accountInfo": { > "accountType": "BRL_ACCOUNT", > "pixKey": "user@email.com", > "pixKeyType": "EMAIL", > "bankName": "Nubank", > "taxId": "12345678900", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "João Silva", > "birthDate": "1988-07-22", > "nationality": "BR", > "address": { > "line1": "Rua das Flores 456", > "city": "São Paulo", > "state": "SP", > "postalCode": "01234-567", > "country": "BR" > } > } > } > }' > ``` > > > [!TIP] > > > > Key types: `CPF`, `CNPJ`, `EMAIL`, `PHONE`, or `RANDOM` > [tab] Colombia > > **Bank Transfer or Mobile Money (Nequi, Daviplata)** > > Colombia supports two payment rails: bank transfer and mobile money. > > **Bank Transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "COP", > "platformAccountId": "co_bank_001", > "accountInfo": { > "accountType": "COP_ACCOUNT", > "bankName": "Bancolombia", > "accountNumber": "1234567890", > "bankAccountType": "CHECKING", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Juan Carlos García", > "birthDate": "1988-03-15", > "nationality": "CO", > "documentType": "CC", > "documentNumber": "1234567890", > "address": { > "line1": "Carrera 7 #45-23", > "city": "Bogotá", > "postalCode": "110111", > "country": "CO" > } > } > } > }' > ``` > > **Mobile Money (Nequi, Daviplata):** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "COP", > "platformAccountId": "co_mobile_001", > "accountInfo": { > "accountType": "COP_ACCOUNT", > "bankName": "Nequi", > "phoneNumber": "+573001234567", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "María López", > "birthDate": "1992-07-20", > "nationality": "CO", > "documentType": "CC", > "documentNumber": "9876543210", > "address": { > "line1": "Calle 100 #15-30", > "city": "Medellín", > "postalCode": "050021", > "country": "CO" > } > } > } > }' > ``` > > > [!NOTE] > > > > Bank transfer requires `bankName`, `accountNumber`, and `bankAccountType` (CHECKING or SAVINGS). > > Mobile money requires `phoneNumber` in international format (e.g., +573001234567). > > Most Colombian banks require `documentType` and `documentNumber` for KYC matching. > > Document types: `CC` (Cédula de Ciudadanía), `CE` (Cédula de Extranjería), `TI` (Tarjeta de Identidad), `NIT` (Número de Identificación Tributaria), `PP` (Passport). > [tab] El Salvador > > **Bank Transfer or Mobile Money (Tigo Money)** > > El Salvador supports two payment rails: bank transfer and mobile money. > > **Bank Transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USD", > "platformAccountId": "sv_bank_001", > "accountInfo": { > "accountType": "SLV_ACCOUNT", > "bankName": "Banco Cuscatlan", > "accountNumber": "0123456789", > "bankAccountType": "CHECKING", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Carlos Morales", > "birthDate": "1990-05-20", > "nationality": "SV", > "address": { > "line1": "Colonia Escalon 123", > "city": "San Salvador", > "postalCode": "01101", > "country": "SV" > } > } > } > }' > ``` > > **Mobile Money (Tigo Money):** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USD", > "platformAccountId": "sv_mobile_001", > "accountInfo": { > "accountType": "SLV_ACCOUNT", > "phoneNumber": "+50312345678", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Ana Martinez", > "birthDate": "1985-08-15", > "nationality": "SV", > "address": { > "line1": "Avenida Roosevelt 456", > "city": "San Salvador", > "postalCode": "01101", > "country": "SV" > } > } > } > }' > ``` > > > [!NOTE] > > > > Bank transfer requires `bankName`, `accountNumber`, and `bankAccountType` (CHECKING or SAVINGS). > > Mobile money requires `phoneNumber` in international format (e.g., +50312345678). > [tab] Europe > > **EUR Account (IBAN)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "EUR", > "platformAccountId": "eu_iban_001", > "accountInfo": { > "accountType": "EUR_ACCOUNT", > "iban": "DE89370400440532013000", > "bankName": "Deutsche Bank", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Hans Schmidt", > "birthDate": "1982-11-08", > "nationality": "DE", > "address": { > "line1": "Hauptstraße 789", > "city": "Berlin", > "state": "Berlin", > "postalCode": "10115", > "country": "DE" > } > } > } > }' > ``` > > When you omit `swiftCode`, Grid derives it from the IBAN when possible. Provide > it when automatic derivation is unavailable. > [tab] Philippines > > **PHP Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "PHP", > "platformAccountId": "ph_bank_001", > "accountInfo": { > "accountType": "PHP_ACCOUNT", > "bankName": "BDO Unibank", > "accountNumber": "001234567890", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Maria Santos", > "birthDate": "1995-04-10", > "nationality": "PH", > "address": { > "line1": "123 Rizal Avenue", > "city": "Manila", > "state": "Metro Manila", > "postalCode": "1000", > "country": "PH" > } > } > } > }' > ``` > > > [!NOTE] > > > > Account number must be 8-16 digits. > [tab] United Kingdom > > **GBP Faster Payments** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "GBP", > "platformAccountId": "gb_bank_001", > "accountInfo": { > "accountType": "GBP_ACCOUNT", > "sortCode": "123456", > "accountNumber": "12345678", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "James Smith", > "birthDate": "1985-09-03", > "nationality": "GB", > "address": { > "line1": "10 Downing Street", > "city": "London", > "postalCode": "SW1A 2AA", > "country": "GB" > } > } > } > }' > ``` > > > [!NOTE] > > > > Sort code must be 6 digits. Account number must be 8 digits. Address is required for GBP individual beneficiaries. > [tab] India > > **UPI, NEFT, or RTGS** > > India supports three payment rails: UPI (instant), NEFT (near-real-time), and RTGS (real-time, for large transfers). > > **UPI:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "INR", > "platformAccountId": "in_upi_001", > "accountInfo": { > "accountType": "INR_ACCOUNT", > "vpa": "user@okbank", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Priya Sharma", > "birthDate": "1991-05-14", > "nationality": "IN", > "address": { > "line1": "123 MG Road", > "city": "Mumbai", > "state": "Maharashtra", > "postalCode": "400001", > "country": "IN" > } > } > } > }' > ``` > > **NEFT / RTGS (bank transfer):** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "INR", > "platformAccountId": "in_neft_001", > "accountInfo": { > "accountType": "INR_ACCOUNT", > "accountNumber": "000111222333", > "ifsc": "HDFC0001234", > "rail": "NEFT", > "bankName": "HDFC Bank", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Priya Sharma", > "birthDate": "1991-05-14", > "nationality": "IN", > "address": { > "line1": "123 MG Road", > "city": "Mumbai", > "state": "Maharashtra", > "postalCode": "400001", > "country": "IN" > } > } > } > }' > ``` > > > [!NOTE] > > > > UPI requires `vpa`. NEFT and RTGS require `accountNumber` (9-18 digits), `ifsc` > > (11-character branch code), and `rail` (`NEFT` or `RTGS`). `bankName` is optional > > on both. Do not send `paymentRails` — Grid returns it on the created account. > [tab] Nigeria > > **NGN Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "NGN", > "platformAccountId": "ng_bank_001", > "accountInfo": { > "accountType": "NGN_ACCOUNT", > "accountNumber": "0123456789", > "bankName": "First Bank of Nigeria", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Chukwuemeka Okonkwo", > "birthDate": "1990-06-20", > "nationality": "NG", > "address": { > "line1": "15 Marina Street", > "city": "Lagos", > "state": "Lagos", > "postalCode": "100001", > "country": "NG" > } > } > } > }' > ``` > > > [!NOTE] > > > > Account number must be exactly 10 digits. > [tab] South Africa > > **ZAR Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "ZAR", > "platformAccountId": "za_bank_001", > "accountInfo": { > "accountType": "ZAR_ACCOUNT", > "accountNumber": "1234567890", > "bankName": "Standard Bank", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Thabo Mbeki", > "birthDate": "1985-04-12", > "nationality": "ZA", > "address": { > "line1": "42 Nelson Mandela Drive", > "city": "Johannesburg", > "state": "Gauteng", > "postalCode": "2000", > "country": "ZA" > } > } > } > }' > ``` > > > [!NOTE] > > > > Account number must be 9-13 digits. > [tab] Kenya > > **KES Mobile Money (M-PESA)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "KES", > "platformAccountId": "ke_mpesa_001", > "accountInfo": { > "accountType": "KES_ACCOUNT", > "phoneNumber": "+254712345678", > "provider": "M-PESA", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Wanjiku Kamau", > "birthDate": "1990-08-20", > "nationality": "KE", > "address": { > "line1": "Kenyatta Avenue 15", > "city": "Nairobi", > "postalCode": "00100", > "country": "KE" > } > } > } > }' > ``` > > > [!NOTE] > > > > Phone number must be in format `+254XXXXXXXXX` (9 digits after country code). Provider must be `M-PESA`. > [tab] Tanzania > > **TZS Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "TZS", > "platformAccountId": "tz_mobile_001", > "accountInfo": { > "accountType": "TZS_ACCOUNT", > "phoneNumber": "+255712345678", > "provider": "VODACOM", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Juma Hassan", > "birthDate": "1988-02-14", > "nationality": "TZ", > "address": { > "line1": "Samora Avenue 28", > "city": "Dar es Salaam", > "postalCode": "11101", > "country": "TZ" > } > } > } > }' > ``` > > > [!NOTE] > > > > Phone number must be in format `+255XXXXXXXXX` (9 digits after country code). Providers: `AIRTEL` or `VODACOM`. > [tab] Zambia > > **ZMW Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "ZMW", > "platformAccountId": "zm_mobile_001", > "accountInfo": { > "accountType": "ZMW_ACCOUNT", > "phoneNumber": "+260971234567", > "provider": "MTN", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Mwila Chanda", > "birthDate": "1992-11-05", > "nationality": "ZM", > "address": { > "line1": "Cairo Road 100", > "city": "Lusaka", > "postalCode": "10101", > "country": "ZM" > } > } > } > }' > ``` > > > [!NOTE] > > > > Phone number must be in format `+260XXXXXXXXX` (9 digits after country code). Providers: `TNM`, `AIRTEL`, `ZAMTEL`, or `MTN`. > [tab] Rwanda > > **RWF Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "RWF", > "platformAccountId": "rw_mobile_001", > "accountInfo": { > "accountType": "RWF_ACCOUNT", > "phoneNumber": "+250781234567", > "provider": "MTN", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Uwimana Diane", > "birthDate": "1995-06-30", > "nationality": "RW", > "address": { > "line1": "KN 5 Avenue", > "city": "Kigali", > "postalCode": "00100", > "country": "RW" > } > } > } > }' > ``` > > > [!NOTE] > > > > Phone number must be in format `+250XXXXXXXXX` (9 digits after country code). Providers: `MTN` or `AIRTEL`. > [tab] Malawi > > **MWK Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "MWK", > "platformAccountId": "mw_mobile_001", > "accountInfo": { > "accountType": "MWK_ACCOUNT", > "phoneNumber": "+265991234567", > "provider": "AIRTEL", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Chimwemwe Banda", > "birthDate": "1993-03-18", > "nationality": "MW", > "address": { > "line1": "Kamuzu Procession Road", > "city": "Lilongwe", > "postalCode": "00100", > "country": "MW" > } > } > } > }' > ``` > > > [!NOTE] > > > > Phone number must be in format `+265XXXXXXXXX` (9 digits after country code). Providers: `AIRTEL` or `TNM`. > [tab] Uganda > > **UGX Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "UGX", > "platformAccountId": "ug_mobile_001", > "accountInfo": { > "accountType": "UGX_ACCOUNT", > "phoneNumber": "+256701234567", > "provider": "MTN", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Nakato Sarah", > "birthDate": "1990-07-25", > "nationality": "UG", > "address": { > "line1": "Kampala Road 45", > "city": "Kampala", > "postalCode": "00100", > "country": "UG" > } > } > } > }' > ``` > > > [!NOTE] > > > > Phone number must be in format `+256XXXXXXXXX` (9 digits after country code). Providers: `MTN` or `AIRTEL`. > [tab] West Africa > > **XOF Mobile Money (Senegal, Benin, Ivory Coast)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "XOF", > "platformAccountId": "xof_mobile_001", > "accountInfo": { > "accountType": "XOF_ACCOUNT", > "phoneNumber": "+221781234567", > "provider": "ORANGE", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Mamadou Diallo", > "birthDate": "1988-11-12", > "nationality": "SN", > "address": { > "line1": "Avenue Cheikh Anta Diop", > "city": "Dakar", > "postalCode": "10000", > "country": "SN" > } > } > } > }' > ``` > > > [!NOTE] > > > > Supported countries and phone formats: > > > > * Senegal (+221): `+221XXXXXXXXX` (9 digits after country code) > > * Ivory Coast (+225): `+225XXXXXXXXXX` (10 digits after country code) > > * Benin (+229): `+229XXXXXXXX` or `+229XXXXXXXXX` (8–9 digits after country code) > [tab] Canada > > **CAD Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "CAD", > "platformAccountId": "ca_bank_001", > "accountInfo": { > "accountType": "CAD_ACCOUNT", > "bankCode": "001", > "branchCode": "00012", > "accountNumber": "1234567", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Emily Thompson", > "birthDate": "1988-09-12", > "nationality": "CA", > "address": { > "line1": "456 Queen Street West", > "city": "Toronto", > "state": "ON", > "postalCode": "M5V 2B3", > "country": "CA" > } > } > } > }' > ``` > > > [!NOTE] > > > > Bank code is 3 digits, branch code is 5 digits, account number is 7-12 digits. > [tab] United Arab Emirates > > **Bank Transfer (IBAN)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "AED", > "platformAccountId": "ae_bank_001", > "accountInfo": { > "accountType": "AED_ACCOUNT", > "iban": "AE070331234567890123456", > "swiftCode": "EBILAEAD", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Fatima Al Mansoori", > "birthDate": "1990-01-15", > "nationality": "AE", > "address": { > "line1": "Sheikh Zayed Road 12", > "city": "Dubai", > "postalCode": "00000", > "country": "AE" > } > } > } > }' > ``` > > > [!NOTE] > > > > `iban` is required and must be a 23-character UAE IBAN starting with `AE`. `swiftCode` is optional. An `address` is required on the beneficiary. > [tab] Israel > > **Bank Transfer (IBAN)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "ILS", > "platformAccountId": "il_bank_001", > "accountInfo": { > "accountType": "ILS_ACCOUNT", > "iban": "IL620108000000099999999", > "bankName": "Bank Leumi", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Noa Levi", > "birthDate": "1990-01-15", > "nationality": "IL", > "address": { > "line1": "Rothschild Boulevard 12", > "city": "Tel Aviv", > "postalCode": "6688112", > "country": "IL" > } > } > } > }' > ``` > > > [!NOTE] > > > > `iban` is required and must be a 23-character Israeli IBAN starting with `IL`. `bankName` is required. Business beneficiaries (`beneficiaryType: "BUSINESS"`) require `legalName` and `address`. > [tab] Bangladesh > > **Bank Transfer or Mobile Money (bKash, Nagad)** > > **Bank transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "BDT", > "platformAccountId": "bd_bank_001", > "accountInfo": { > "accountType": "BDT_ACCOUNT", > "bankName": "BRAC Bank", > "accountNumber": "1234567890123", > "branchCode": "12345", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Rahim Chowdhury", > "birthDate": "1990-01-15", > "nationality": "BD", > "address": { > "line1": "12 Gulshan Avenue", > "city": "Dhaka", > "postalCode": "1212", > "country": "BD" > } > } > } > }' > ``` > > **Mobile money:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "BDT", > "platformAccountId": "bd_mobile_001", > "accountInfo": { > "accountType": "BDT_ACCOUNT", > "bankName": "bKash", > "phoneNumber": "+8801712345678", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Rahim Chowdhury", > "birthDate": "1990-01-15", > "nationality": "BD", > "address": { > "line1": "12 Gulshan Avenue", > "city": "Dhaka", > "postalCode": "1212", > "country": "BD" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName` is always required. Bank transfer additionally uses `accountNumber` and optionally `branchCode`; mobile money uses `phoneNumber`. `swiftCode` is optional on bank transfers. > [tab] Botswana > > **Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "BWP", > "platformAccountId": "bw_mobile_001", > "accountInfo": { > "accountType": "BWP_ACCOUNT", > "phoneNumber": "+26771234567", > "provider": "Orange", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Kagiso Molefe", > "birthDate": "1990-01-15", > "nationality": "BW", > "address": { > "line1": "Plot 123 Main Mall", > "city": "Gaborone", > "postalCode": "00000", > "country": "BW" > } > } > } > }' > ``` > > > [!NOTE] > > > > Both `phoneNumber` (international format) and `provider` are required. > [tab] China > > **AliPay / WeChat Pay or Bank Transfer** > > **Bank transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "CNY", > "platformAccountId": "cn_bank_001", > "accountInfo": { > "accountType": "CNY_ACCOUNT", > "bankName": "Industrial and Commercial Bank of China", > "accountNumber": "6217000010000000001", > "beneficiary": { > "beneficiaryType": "BUSINESS", > "legalName": "Shanghai Trading Co Ltd", > "registrationNumber": "91110000000000000X", > "address": { > "line1": "1 Nanjing Road", > "city": "Shanghai", > "postalCode": "200000", > "country": "CN" > } > } > } > }' > ``` > > **AliPay / WeChat Pay:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "CNY", > "platformAccountId": "cn_mobile_001", > "accountInfo": { > "accountType": "CNY_ACCOUNT", > "bankName": "AliPay", > "phoneNumber": "+8613812345678", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Wei Zhang", > "birthDate": "1990-01-15", > "nationality": "CN", > "address": { > "line1": "1 Nanjing Road", > "city": "Shanghai", > "postalCode": "200000", > "country": "CN" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName` is always required. For a wallet payout it selects the wallet and must be > > exactly `AliPay` or `WechatPay`, paired with `phoneNumber`. Bank transfer uses > > `accountNumber` with the beneficiary bank's name, and is business-to-business only — > > send `beneficiaryType: "BUSINESS"`. A bank transfer payout also needs supporting > > documents. See [Supporting > > documents](/payouts-and-b2b/payment-flow/send-payment#supporting-documents). > [tab] Egypt > > **Bank Transfer or Mobile Money** > > **Bank transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "EGP", > "platformAccountId": "eg_bank_001", > "accountInfo": { > "accountType": "EGP_ACCOUNT", > "bankName": "National Bank of Egypt", > "iban": "EG380019000500000000263180002", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Nour Hassan", > "birthDate": "1990-01-15", > "nationality": "EG", > "address": { > "line1": "15 Tahrir Square", > "city": "Cairo", > "postalCode": "11511", > "country": "EG" > } > } > } > }' > ``` > > **Mobile money:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "EGP", > "platformAccountId": "eg_mobile_001", > "accountInfo": { > "accountType": "EGP_ACCOUNT", > "bankName": "Vodafone Cash", > "phoneNumber": "+201012345678", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Nour Hassan", > "birthDate": "1990-01-15", > "nationality": "EG", > "address": { > "line1": "15 Tahrir Square", > "city": "Cairo", > "postalCode": "11511", > "country": "EG" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName` is always required. Bank transfer uses `iban`, a 29-character Egyptian IBAN starting with `EG`; mobile money uses `phoneNumber`. > [tab] Ghana > > **Bank Transfer or Mobile Money (MTN, Vodafone)** > > **Bank transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "GHS", > "platformAccountId": "gh_bank_001", > "accountInfo": { > "accountType": "GHS_ACCOUNT", > "bankName": "GCB Bank", > "accountNumber": "1234567890", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Kwame Mensah", > "birthDate": "1990-01-15", > "nationality": "GH", > "address": { > "line1": "23 Independence Avenue", > "city": "Accra", > "postalCode": "00233", > "country": "GH" > } > } > } > }' > ``` > > **Mobile money:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "GHS", > "platformAccountId": "gh_mobile_001", > "accountInfo": { > "accountType": "GHS_ACCOUNT", > "bankName": "MTN Mobile Money", > "phoneNumber": "+233241234567", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Kwame Mensah", > "birthDate": "1990-01-15", > "nationality": "GH", > "address": { > "line1": "23 Independence Avenue", > "city": "Accra", > "postalCode": "00233", > "country": "GH" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName` is always required. Bank transfer uses `accountNumber`; mobile money uses `phoneNumber`. > [tab] Guatemala > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "GTQ", > "platformAccountId": "gt_bank_001", > "accountInfo": { > "accountType": "GTQ_ACCOUNT", > "accountNumber": "1234567890", > "bankAccountType": "CHECKING", > "bankName": "Banco GYT Continental", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Ana Lucía Ramírez", > "birthDate": "1990-01-15", > "nationality": "GT", > "phoneNumber": "+50251234567", > "countryOfResidence": "GT", > "address": { > "line1": "5a Avenida 12-34 Zona 10", > "city": "Guatemala City", > "postalCode": "01010", > "country": "GT" > } > } > } > }' > ``` > > > [!NOTE] > > > > `accountNumber`, `bankAccountType` (`CHECKING` or `SAVINGS`) and `bankName` are all required. The beneficiary additionally requires `phoneNumber` and `countryOfResidence`. > [tab] Haiti > > **Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "HTG", > "platformAccountId": "ht_mobile_001", > "accountInfo": { > "accountType": "HTG_ACCOUNT", > "phoneNumber": "+50934567890", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Marie Pierre-Louis", > "birthDate": "1990-01-15", > "nationality": "HT", > "address": { > "line1": "45 Rue Capois", > "city": "Port-au-Prince", > "postalCode": "6110", > "country": "HT" > } > } > } > }' > ``` > > > [!NOTE] > > > > `phoneNumber` in international format is the only required account field. > [tab] Jamaica > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "JMD", > "platformAccountId": "jm_bank_001", > "accountInfo": { > "accountType": "JMD_ACCOUNT", > "accountNumber": "1234567890", > "branchCode": "12345", > "bankAccountType": "CHECKING", > "bankName": "Bank of Nova Scotia", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Andre Campbell", > "birthDate": "1990-01-15", > "nationality": "JM", > "phoneNumber": "+18761234567", > "address": { > "line1": "10 Knutsford Boulevard", > "city": "Kingston", > "postalCode": "00000", > "country": "JM" > } > } > } > }' > ``` > > > [!NOTE] > > > > `accountNumber`, `branchCode` (5 digits), `bankAccountType` (`CHECKING` or `SAVINGS`) and `bankName` are all required. The beneficiary additionally requires `phoneNumber` and an `address`. > [tab] Pakistan > > **Bank Transfer or Mobile Money (JazzCash, Easypaisa)** > > **Bank transfer:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "PKR", > "platformAccountId": "pk_bank_001", > "accountInfo": { > "accountType": "PKR_ACCOUNT", > "bankName": "HBL", > "accountNumber": "1234567890123456", > "iban": "PK36SCBL0000001123456702", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Ayesha Khan", > "birthDate": "1990-01-15", > "nationality": "PK", > "address": { > "line1": "12 Clifton Block 5", > "city": "Karachi", > "postalCode": "75600", > "country": "PK" > } > } > } > }' > ``` > > **Mobile money:** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "PKR", > "platformAccountId": "pk_mobile_001", > "accountInfo": { > "accountType": "PKR_ACCOUNT", > "bankName": "JazzCash", > "phoneNumber": "+923001234567", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Ayesha Khan", > "birthDate": "1990-01-15", > "nationality": "PK", > "address": { > "line1": "12 Clifton Block 5", > "city": "Karachi", > "postalCode": "75600", > "country": "PK" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName` is always required. Bank transfer uses `accountNumber`, and optionally `iban` (24 characters, starting with `PK`); mobile money uses `phoneNumber`. > [tab] Central Africa > > **Mobile Money** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "XAF", > "platformAccountId": "xaf_mobile_001", > "accountInfo": { > "accountType": "XAF_ACCOUNT", > "phoneNumber": "+237671234567", > "provider": "MTN", > "region": "CM", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Achille Nkeng", > "birthDate": "1990-01-15", > "nationality": "CM", > "address": { > "line1": "Rue Joss 20", > "city": "Douala", > "postalCode": "00237", > "country": "CM" > } > } > } > }' > ``` > > > [!NOTE] > > > > `phoneNumber`, `provider` and `region` are all required. `region` must be `CM` (Cameroon) or `CG` (Republic of the Congo). > [tab] Denmark > > **Bank Transfer (IBAN)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "DKK", > "platformAccountId": "dk_bank_001", > "accountInfo": { > "accountType": "DKK_ACCOUNT", > "iban": "DK5000400040116243", > "swiftCode": "DABADKKK", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Lars Jensen", > "birthDate": "1990-01-15", > "nationality": "DK", > "address": { > "line1": "Vesterbrogade 10", > "city": "Copenhagen", > "postalCode": "1620", > "country": "DK" > } > } > } > }' > ``` > > > [!NOTE] > > > > `iban` is required; `swiftCode` is optional. > [tab] Hong Kong > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "HKD", > "platformAccountId": "hk_bank_001", > "accountInfo": { > "accountType": "HKD_ACCOUNT", > "bankName": "HSBC Hong Kong", > "accountNumber": "123456789012", > "swiftCode": "HSBCHKHHHKH", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Emily Chan", > "birthDate": "1990-01-15", > "nationality": "HK", > "address": { > "line1": "1 Queen's Road Central", > "city": "Hong Kong", > "postalCode": "00000", > "country": "HK" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName`, `accountNumber` and `swiftCode` are all required. > [tab] Indonesia > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "IDR", > "platformAccountId": "id_bank_001", > "accountInfo": { > "accountType": "IDR_ACCOUNT", > "bankName": "Bank Central Asia", > "accountNumber": "1234567890", > "swiftCode": "CENAIDJA", > "phoneNumber": "+6281234567890", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Siti Rahayu", > "birthDate": "1990-01-15", > "nationality": "ID", > "address": { > "line1": "Jl. Sudirman 45", > "city": "Jakarta", > "postalCode": "10210", > "country": "ID" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName`, `accountNumber`, `swiftCode` and `phoneNumber` are all required. > [tab] Malaysia > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "MYR", > "platformAccountId": "my_bank_001", > "accountInfo": { > "accountType": "MYR_ACCOUNT", > "bankName": "Maybank", > "accountNumber": "1234567890", > "swiftCode": "MABORUMMYYY", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Nurul Aisyah", > "birthDate": "1990-01-15", > "nationality": "MY", > "address": { > "line1": "Jalan Ampang 88", > "city": "Kuala Lumpur", > "postalCode": "50450", > "country": "MY" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName`, `accountNumber` and `swiftCode` are all required. > [tab] Singapore > > **Bank Transfer (PayNow, FAST)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "SGD", > "platformAccountId": "sg_bank_001", > "accountInfo": { > "accountType": "SGD_ACCOUNT", > "bankName": "DBS Bank Ltd", > "accountNumber": "0123456789", > "swiftCode": "DBSSSGSG", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Wei Lin Tan", > "birthDate": "1990-01-15", > "nationality": "SG", > "address": { > "line1": "12 Marina Boulevard", > "city": "Singapore", > "postalCode": "018982", > "country": "SG" > } > } > } > }' > ``` > > > [!NOTE] > > > > `accountNumber` and `swiftCode` are required; `bankName` is optional. > [tab] Thailand > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "THB", > "platformAccountId": "th_bank_001", > "accountInfo": { > "accountType": "THB_ACCOUNT", > "bankName": "Bangkok Bank", > "accountNumber": "1234567890", > "swiftCode": "BKKBTHBK", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Somchai Prasert", > "birthDate": "1990-01-15", > "nationality": "TH", > "address": { > "line1": "333 Silom Road", > "city": "Bangkok", > "postalCode": "10500", > "country": "TH" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName`, `accountNumber` and `swiftCode` are all required. > [tab] Vietnam > > **Bank Transfer** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "VND", > "platformAccountId": "vn_bank_001", > "accountInfo": { > "accountType": "VND_ACCOUNT", > "bankName": "Vietcombank", > "accountNumber": "1234567890", > "swiftCode": "BFTVVNVX", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Nguyen Thi Lan", > "birthDate": "1990-01-15", > "nationality": "VN", > "address": { > "line1": "22 Le Loi", > "city": "Ho Chi Minh City", > "postalCode": "70000", > "country": "VN" > } > } > } > }' > ``` > > > [!NOTE] > > > > `bankName`, `accountNumber` and `swiftCode` are all required. > [tab] SWIFT (International) > > **SWIFT / International Wire** > > Use a `SWIFT_ACCOUNT` external account to send payouts to bank accounts in countries or currencies where Grid doesn't offer a local payment rail. Provide the bank's SWIFT/BIC code and the beneficiary's account number (or IBAN for IBAN-based corridors). > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USD", > "platformAccountId": "swift_usd_001", > "accountInfo": { > "accountType": "SWIFT_ACCOUNT", > "country": "NG", > "swiftCode": "GTBINGLA", > "bankName": "Guaranty Trust Bank", > "accountNumber": "1234567890", > "beneficiary": { > "beneficiaryType": "INDIVIDUAL", > "fullName": "Adaeze Okafor", > "birthDate": "1991-03-22", > "nationality": "NG", > "address": { > "line1": "15 Broad Street", > "city": "Lagos", > "state": "Lagos", > "postalCode": "101233", > "country": "NG" > } > } > } > }' > ``` > > > [!NOTE] > > > > `currency` is the currency the destination account holds, and `country` is where the bank > > account is located — they can differ. Provide `accountNumber` for most corridors; use `iban` > > instead for IBAN-only corridors (e.g. GB, BR). > [tab] Cryptocurrency > > **Bitcoin Lightning (Spark Wallet)** > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "BTC", > "platformAccountId": "btc_spark_001", > "accountInfo": { > "accountType": "SPARK_WALLET", > "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu" > } > }' > ``` > > **Ethereum L1** > > Supported assets: USDC and USDT. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDC", > "platformAccountId": "eth_usdc_001", > "accountInfo": { > "accountType": "ETHEREUM_WALLET", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > }' > ``` > > **Base** > > Supported assets: USDC. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDC", > "platformAccountId": "base_usdc_001", > "accountInfo": { > "accountType": "BASE_WALLET", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > }' > ``` > > **Polygon** > > Supported assets: USDC. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDC", > "platformAccountId": "polygon_usdc_001", > "accountInfo": { > "accountType": "POLYGON_WALLET", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > }' > ``` > > **Solana** > > Supported assets: USDC. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDC", > "platformAccountId": "sol_usdc_001", > "accountInfo": { > "accountType": "SOLANA_WALLET", > "address": "4Nd1m6Qkq7RfKuE5vQ9qP9Tn6H94Ueqb4xXHzsAbd8Wg" > } > }' > ``` > > **Tron** > > Supported assets: USDT. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDT", > "platformAccountId": "tron_usdt_001", > "accountInfo": { > "accountType": "TRON_WALLET", > "address": "TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL" > } > }' > ``` > > **Plasma** > > Supported assets: USDT. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDT", > "platformAccountId": "plasma_usdt_001", > "accountInfo": { > "accountType": "PLASMA_WALLET", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > }' > ``` > > **Arbitrum** > > Supported assets: USDC, USDT. > > ```bash cURL USDC 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDC", > "platformAccountId": "arbitrum_usdc_001", > "accountInfo": { > "accountType": "ARBITRUM_WALLET", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > }' > ``` > > ```bash cURL USDT 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "USDT", > "platformAccountId": "arbitrum_usdt_001", > "accountInfo": { > "accountType": "ARBITRUM_WALLET", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > }' > ``` > > **Lightning** > > Exactly one of `lightningAddress`, `invoice` (a single-use BOLT11 invoice) or `bolt12` > (a reusable offer) must be provided. > > ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", > "currency": "BTC", > "platformAccountId": "btc_lightning_001", > "accountInfo": { > "accountType": "LIGHTNING", > "lightningAddress": "john.doe@lightningwallet.com" > } > }' > ``` > > > [!NOTE] > > > > Crypto wallets don't require beneficiary information. Send only assets on the > > named network — EVM addresses look identical across Ethereum, Base, Polygon, > > Plasma, and Arbitrum, and funds sent on the wrong network are unrecoverable. > [!TIP] > > Use `platformAccountId` to tie your internal id with the external account. **Sample Response:** ```json theme={null} { "id": "ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "status": "ACTIVE", "currency": "USD", "platformAccountId": "user_123_primary_bank", "accountInfo": { "accountType": "USD_ACCOUNT", "accountNumber": "123456789", "routingNumber": "021000021", "bankAccountType": "CHECKING", "bankName": "Chase Bank", "beneficiary": { "beneficiaryType": "INDIVIDUAL", "fullName": "John Doe", "birthDate": "1990-01-15", "nationality": "US", "address": { "line1": "123 Main Street", "city": "San Francisco", "state": "CA", "postalCode": "94105", "country": "US" } } } } ``` ### Business beneficiaries For business accounts, include business information: ```json theme={null} { "currency": "USD", "platformAccountId": "acme_corp_account", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "accountInfo": { "accountType": "USD_ACCOUNT", "accountNumber": "987654321", "routingNumber": "021000021", "bankAccountType": "CHECKING", "bankName": "Chase Bank", "beneficiary": { "beneficiaryType": "BUSINESS", "businessInfo": { "legalName": "Acme Corporation, Inc.", "taxId": "EIN-987654321" }, "address": { "line1": "456 Business Ave", "city": "New York", "state": "NY", "postalCode": "10001", "country": "US" } } } } ``` ## Minimum required beneficiary fields The following tables show the minimum required fields for individual and business beneficiaries. All other fields are optional but recommended for faster compliance review. ### Individual beneficiaries | Country | Required Fields | Required Address Fields | | - | - | - | | US (USD) | `beneficiaryType`, `fullName` | `line1`, `city`, `postalCode`, `country` | | Mexico (MXN) | `beneficiaryType`, `fullName` | None (address optional) | | Brazil (BRL) | `beneficiaryType`, `fullName` | None (address optional) | | Philippines (PHP) | `beneficiaryType`, `fullName` | None (address optional) | | United Kingdom (GBP) | `beneficiaryType`, `fullName`, `address` | `line1`, `city`, `postalCode`, `country` | | Europe (EUR) | `beneficiaryType`, `fullName`, `address` | `line1`, `city`, `postalCode`, `country` | > [!NOTE] > > While only the fields listed above are strictly required, providing additional information like `birthDate`, `nationality`, and `address` can reduce the likelihood of false positive compliance checks and increase transaction success rates. ### Business beneficiaries For business beneficiaries, the required fields vary by destination currency: | Currency | Required fields | | - | - | | USD | `legalName`, `address` | | MXN | `legalName`, `address` | | EUR | `legalName`, `address`, `registrationNumber` | | GBP, INR, BRL, DKK, PHP, HKD, IDR, MYR, SGD, THB, VND, AED, ILS | `legalName`, `address` | | NGN, ZAR, KES, TZS, RWF, ZMW, UGX, BWP, MWK, XOF, XAF | `legalName`, `address`, `registrationNumber`, `taxId` | > [!NOTE] > > When sending to GBP, INR, BRL, DKK, PHP, HKD, IDR, MYR, SGD, THB, VND, AED, or ILS, business senders (originators) must also provide `registrationNumber`. > [!NOTE] > > `registrationNumber` is the business's official registration or incorporation number (e.g., EIN in the US, CNPJ in Brazil, company registration number in the UK). `taxId` is the business's tax identification number where it differs from the registration number. ## Account status Beneficiary data may be reviewed for risk and compliance. Only `ACTIVE` accounts can receive payments. Updates to account data may trigger account re-review. | Status | Description | | - | - | | `PENDING` | Created, awaiting verification | | `ACTIVE` | Verified and ready for transactions | | `UNDER_REVIEW` | Additional review required | | `INACTIVE` | Disabled, cannot be used | ## Listing external accounts ### List customer accounts ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/external-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ### List platform accounts For platform-wide operations, list all platform-level external accounts: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/platform/external-accounts' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [!NOTE] > > Platform external accounts are used for platform-wide operations like > depositing funds from external sources. ## Retrieving a single external account Fetch one external account by its system-generated ID: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/external-accounts/ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response:** ```json theme={null} { "id": "ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "status": "ACTIVE", "currency": "USD", "platformAccountId": "acc_123456789", "accountInfo": { "accountType": "USD_ACCOUNT", "accountNumber": "1234567890", "routingNumber": "021000021", "bankName": "Chase Bank", "bankAccountType": "CHECKING", "paymentRails": ["ACH", "WIRE", "RTP", "FEDNOW"], "beneficiary": { "beneficiaryType": "INDIVIDUAL", "fullName": "Jane Doe" } } } ``` > [!NOTE] > > `paymentRails` reports the rails Grid selected for the account. It is > returned on the account and is not something you send when creating one. Returns `404` if no external account matches the ID. ## Deleting an external account Delete an external account by its system-generated ID: ```bash theme={null} curl -X DELETE 'https://api.lightspark.com/grid/2025-10-13/customers/external-accounts/ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` Returns `204 No Content` on success. > [!WARNING] > > An account that is currently a **trusted beneficiary** for Strong Customer > Authentication cannot be deleted — the request fails with `409` and code > `BENEFICIARY_TRUSTED`. Untrust it first via `POST > /customers/external-accounts/{externalAccountId}/untrust` and its > `/confirm`, then delete. ## Best practices > [accordion] Validate account information > > Validate account details before submission: > > ```javascript theme={null} > // US accounts: 9-digit routing, 4-17 digit account number > if (!/^\d{9}$/.test(routingNumber)) { > throw new Error("Invalid routing number"); > } > > // CLABE: exactly 18 digits > if (!/^\d{18}$/.test(clabeNumber)) { > throw new Error("Invalid CLABE number"); > } > > // NGN: exactly 10-digit account number > if (!/^\d{10}$/.test(ngnAccountNumber)) { > throw new Error("Invalid Nigerian account number"); > } > > // CAD: 3-digit bank code, 5-digit branch code, 7-12 digit account number > if (!/^\d{3}$/.test(bankCode)) { > throw new Error("Invalid bank code"); > } > if (!/^\d{5}$/.test(branchCode)) { > throw new Error("Invalid branch code"); > } > if (!/^\d{7,12}$/.test(cadAccountNumber)) { > throw new Error("Invalid Canadian account number"); > } > > // PHP: 8-16 digit account number > if (!/^\d{8,16}$/.test(phpAccountNumber)) { > throw new Error("Invalid Philippine account number"); > } > > // GBP: 6-digit sort code, 8-digit account number > if (!/^\d{6}$/.test(sortCode)) { > throw new Error("Invalid sort code"); > } > if (!/^\d{8}$/.test(gbpAccountNumber)) { > throw new Error("Invalid UK account number"); > } > > // ZAR: 9-13 digit account number > if (!/^\d{9,13}$/.test(zarAccountNumber)) { > throw new Error("Invalid South African account number"); > } > > // African mobile money phone numbers > if (!/^\+254\d{9}$/.test(kesPhoneNumber)) { > throw new Error("Invalid Kenyan phone number"); > } > if (!/^\+255\d{9}$/.test(tzsPhoneNumber)) { > throw new Error("Invalid Tanzanian phone number"); > } > if (!/^\+260\d{9}$/.test(zmwPhoneNumber)) { > throw new Error("Invalid Zambian phone number"); > } > if (!/^\+250\d{9}$/.test(rwfPhoneNumber)) { > throw new Error("Invalid Rwandan phone number"); > } > if (!/^\+265\d{9}$/.test(mwkPhoneNumber)) { > throw new Error("Invalid Malawian phone number"); > } > if (!/^\+256\d{9}$/.test(ugxPhoneNumber)) { > throw new Error("Invalid Ugandan phone number"); > } > // West African (XOF): Senegal (+221), Ivory Coast (+225), Benin (+229) > if (!/^\+(221\d{9}|225\d{10}|229\d{8,9})$/.test(xofPhoneNumber)) { > throw new Error("Invalid West African phone number"); > } > ``` > [accordion] Check account status > > Verify status before sending payments: > > ```javascript theme={null} > if (account.status !== "ACTIVE") { > throw new Error(`Account is ${account.status}, cannot process payment`); > } > ``` > [accordion] Secure account data > > Never expose full account numbers. Display only masked info: > > ```javascript theme={null} > function displaySafely(account) { > return { > id: account.id, > bankName: account.accountInfo.bankName, > lastFour: account.accountInfo.accountNumber.slice(-4), > status: account.status, > }; > } > ``` ## Using external accounts for ramps External accounts serve as destinations for ramp conversions: ### For on-ramps (Fiat → Crypto) External accounts represent crypto wallet destinations: * **Spark wallets**: Lightning Network wallets for instant Bitcoin delivery * **Self-custody**: User-controlled wallets for full ownership * **No beneficiary required**: Crypto wallets don't need compliance information > [!TIP] > > Spark wallets are the recommended destination for on-ramps due to instant > settlement and minimal fees. ### For off-ramps (Crypto → Fiat) External accounts represent bank account destinations: * **Traditional bank accounts**: `USD_ACCOUNT`, `EUR_ACCOUNT`, `MXN_ACCOUNT`, `BRL_ACCOUNT`, `INR_ACCOUNT`, and `SWIFT_ACCOUNT` for corridors with no local rail * **Beneficiary required**: Full compliance information needed for fiat destinations * **Multiple currencies**: Support for USD, EUR, MXN, BRL, INR, and more > [!WARNING] > > Off-ramp destinations require complete beneficiary information for compliance. > Ensure all required fields are provided. ## Crypto wallet destinations ### Spark wallet addresses The primary destination type for on-ramps: ```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:019542f5-b3e7-1d02-0000-000000000001", "currency": "BTC", "platformAccountId": "user_wallet_001", "accountInfo": { "accountType": "SPARK_WALLET", "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu" } }' ``` **Response:** ```json theme={null} { "id": "ExternalAccount:wallet001", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "status": "ACTIVE", "currency": "BTC", "platformAccountId": "user_wallet_001", "accountInfo": { "accountType": "SPARK_WALLET", "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu" }, "createdAt": "2025-10-03T14:00:00Z" } ``` > [!TIP] > > Spark wallet external accounts are immediately `ACTIVE` and ready for on-ramp > conversions. ### Validate Spark addresses Before creating external accounts, validate Spark wallet addresses: ```javascript theme={null} function isValidSparkAddress(address) { // Spark addresses start with 'spark1' and are 87 characters return address.startsWith("spark1") && address.length === 87; } const walletAddress = "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu"; if (!isValidSparkAddress(walletAddress)) { throw new Error("Invalid Spark wallet address format"); } // Create external account await createExternalAccount({ customerId, currency: "BTC", accountInfo: { accountType: "SPARK_WALLET", address: walletAddress, }, }); ``` > [!NOTE] > > Spark addresses are case-insensitive and follow the bech32 format starting > with `spark1`. ## Bank account destinations For off-ramp flows, create external bank accounts with full beneficiary information: ### Example: US bank account for off-ramp ```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:019542f5-b3e7-1d02-0000-000000000001", "currency": "USD", "platformAccountId": "user_bank_usd_001", "accountInfo": { "accountType": "USD_ACCOUNT", "accountNumber": "123456789", "routingNumber": "021000021", "bankAccountType": "CHECKING", "bankName": "Chase Bank", "beneficiary": { "beneficiaryType": "INDIVIDUAL", "fullName": "John Doe", "birthDate": "1990-01-15", "nationality": "US", "address": { "line1": "123 Main Street", "city": "San Francisco", "state": "CA", "postalCode": "94105", "country": "US" } } } }' ``` ### Example: SWIFT account for off-ramp For destinations where Grid has no local payment rail, use a `SWIFT_ACCOUNT` to pay out over international wire. `currency` is the currency the destination account holds, and `country` is where the bank account is located: ```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:019542f5-b3e7-1d02-0000-000000000001", "currency": "USD", "platformAccountId": "user_swift_001", "accountInfo": { "accountType": "SWIFT_ACCOUNT", "country": "NG", "swiftCode": "GTBINGLA", "bankName": "Guaranty Trust Bank", "accountNumber": "1234567890", "beneficiary": { "beneficiaryType": "INDIVIDUAL", "fullName": "Adaeze Okafor", "birthDate": "1991-03-22", "nationality": "NG", "address": { "line1": "15 Broad Street", "city": "Lagos", "state": "Lagos", "postalCode": "101233", "country": "NG" } } } }' ``` > [!NOTE] > > Provide `accountNumber` for most corridors. For IBAN-only corridors such as GB and BR, provide > `iban` instead. ## Listing external accounts ### List customer external accounts ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/external-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ### Filter by currency ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/external-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001¤cy=BTC' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ## Account status and verification External accounts move through verification states: | Status | Description | Can Use for Conversions | | - | - | - | | `PENDING` | Verification in progress | ❌ | | `ACTIVE` | Verified and ready | ✅ | | `UNDER_REVIEW` | Additional review required | ❌ | | `INACTIVE` | Manually disabled | ❌ | > [!NOTE] > > Spark wallet accounts are immediately `ACTIVE`. Bank accounts may require > verification (typically instant to a few hours). ## Best practices for ramps > [accordion] Validate addresses before creation > > Always validate wallet addresses and bank account details before creating external accounts: > > ```javascript theme={null} > // Validate Spark address format > function validateSparkAddress(address) { > if (!address.startsWith("spark1")) { > throw new Error("Spark address must start with spark1"); > } > if (address.length !== 87) { > throw new Error("Spark address must be 87 characters"); > } > // Additional validation logic > return true; > } > ``` > [accordion] Use platform account IDs for tracking > > Map external accounts to your internal system using `platformAccountId`: > > ```javascript theme={null} > const externalAccount = await createExternalAccount({ > customerId, > currency: "BTC", > platformAccountId: `${userId}_spark_primary`, // Your internal ID > accountInfo: { > accountType: "SPARK_WALLET", > address: sparkAddress, > }, > }); > > // Store mapping in your database > await db.userWallets.create({ > userId, > gridAccountId: externalAccount.id, > internalId: `${userId}_spark_primary`, > }); > ``` > [accordion] Handle multiple destinations per customer > > Support multiple wallets or bank accounts for flexibility: > > ```javascript theme={null} > // Primary Spark wallet for on-ramps > await createExternalAccount({ > customerId, > currency: "BTC", > platformAccountId: `${userId}_spark_primary`, > accountInfo: { accountType: "SPARK_WALLET", address: primaryWallet }, > }); > > // Secondary wallet for larger amounts > await createExternalAccount({ > customerId, > currency: "BTC", > platformAccountId: `${userId}_spark_savings`, > accountInfo: { accountType: "SPARK_WALLET", address: savingsWallet }, > }); > ``` > [accordion] Implement address verification for crypto > > For crypto destinations, implement additional verification: > > ```javascript theme={null} > // Verify Spark wallet is reachable (optional) > async function verifySparkWallet(address) { > try { > // Use Lightning Network tools to verify wallet exists > const probe = await lightningClient.probeWallet(address); > return probe.reachable; > } catch (error) { > console.error("Wallet verification failed:", error); > return false; > } > } > > // Only create external account after verification > if (await verifySparkWallet(sparkAddress)) { > await createExternalAccount({ > /* ... */ > }); > } > ``` ## Ramp-specific considerations ### On-ramp destinations * **Instant delivery**: Spark wallets receive Bitcoin within seconds * **No KYC required**: Self-custody wallets don't need beneficiary info * **Reusable addresses**: Store and reuse Spark addresses for multiple conversions * **No minimum**: Send any amount supported by Lightning Network ### Off-ramp destinations * **Full compliance**: Bank accounts require complete beneficiary information * **Verification delays**: Bank account verification may take a few hours * **Settlement times**: Vary by destination (instant for RTP/PIX, 1-3 days for ACH) * **Amount limits**: Check minimum and maximum amounts per destination currency > [!WARNING] > > Always verify account details before initiating large off-ramp conversions. > Test with small amounts first. ## Next steps * [Fiat-to-Crypto Conversion](/ramps/conversion-flows/fiat-crypto-conversion) - Build conversion flows * [Self-Custody Wallets](/ramps/conversion-flows/self-custody-wallets) - Advanced wallet integration * [Webhooks](/ramps/platform-tools/webhooks) - Handle account status updates ## Related resources * [Platform Configuration](/ramps/onboarding/platform-configuration) - Configure supported account types * [Sandbox Testing](/ramps/platform-tools/sandbox-testing) - Test with mock accounts * [API Reference](/api-reference) - Complete API documentation # Internal Accounts Source: https://agents-docs.lightspark.com/ramps/accounts/internal-accounts Manage internal accounts for holding fiat and crypto balances for ramp operations Internal accounts are Lightspark managed accounts that hold funds within the Grid platform. They allow you to receive deposits and send payments to external bank accounts or other payment destinations. They are useful for holding funds on behalf of the platform or customers which will be used for instant, 24/7 quotes and transfers out of the system. Internal accounts are created for both: * **Platform-level accounts**: Hold pooled funds for your platform operations (rewards distribution, reconciliation, etc.) * **Customer accounts**: Hold individual customer funds for their transactions > [!NOTE] > > Internal accounts are automatically created when you onboard a customer, based > on your platform's currency configuration. Platform-level internal accounts > are created when you configure your platform with supported currencies. > [!WARNING] > > On unregulated platforms, a customer's internal accounts aren't provisioned > until you record their End User Terms acceptance in `agreementConsents`, and > customer-scoped transactions are rejected with `403 > END_USER_TERMS_NOT_ACCEPTED` until then. See > [Disclosures](/payouts-and-b2b/onboarding/disclosures). ## How internal accounts work Internal accounts act as an intermediary holding account in the payment flow: 1. **Deposit funds**: You or your customers deposit money into internal accounts using bank transfers (ACH, wire, PIX, etc.) or crypto transfers 2. **Hold balance**: Funds are held securely in the internal account until needed 3. **Send payments**: You initiate transfers from internal accounts to external destinations Each internal account: * Is denominated in a single currency (USD, EUR, etc.) * Has a unique balance that you can query at any time * Includes unique payment instructions for depositing funds * Supports multiple funding methods depending on the currency ## Retrieving internal accounts ### List customer internal accounts To retrieve all internal accounts for a specific customer, use the customer ID to filter the results: ```bash Request internal accounts for a customer theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [accordion] Response > > ```json theme={null} > { > "data": [ > { > "id": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "type": "INTERNAL_FIAT", > "status": "ACTIVE", > "balance": { > "amount": 50000, > "currency": { > "code": "USD", > "name": "United States Dollar", > "symbol": "$", > "decimals": 2 > } > }, > "totalBalance": { > "amount": 50000, > "currency": { > "code": "USD", > "name": "United States Dollar", > "symbol": "$", > "decimals": 2 > } > }, > "fundingPaymentInstructions": [ > { > "accountOrWalletInfo": { > "reference": "FUND-ABC123", > "accountType": "USD_ACCOUNT", > "accountNumber": "9876543210", > "routingNumber": "021000021", > "bankName": "JP Morgan Chase", > "bankAddress": "270 Park Avenue, New York, NY 10017" > } > }, > { > "accountOrWalletInfo": { > "accountType": "SOLANA_WALLET", > "assetType": "USDC", > "address": "4Nd1m6Qkq7RfKuE5vQ9qP9Tn6H94Ueqb4xXHzsAbd8Wg" > } > } > ], > "createdAt": "2025-10-03T12:00:00Z", > "updatedAt": "2025-10-03T14:30:00Z" > } > ], > "hasMore": false, > "totalCount": 1 > } > ``` ### Filter by currency You can filter internal accounts by currency to find accounts for specific denominations: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001¤cy=USD' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ### List platform internal accounts To retrieve platform-level internal accounts (not tied to individual customers), use the platform internal accounts endpoint: ```bash theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/platform/internal-accounts' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [!NOTE] > > Platform internal accounts are useful for managing pooled funds, distributing > rewards, or handling platform-level operations. ## Understanding funding payment instructions Each internal account includes `fundingPaymentInstructions` that tell your customers how to deposit funds. The structure varies by payment rail and currency: > [accordion] US ACH or wire transfers > > For USD accounts, instructions include routing and account numbers: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "USD_ACCOUNT", > "reference": "FUND-ABC123", > "accountNumber": "9876543210", > "routingNumber": "021000021", > "bankName": "JP Morgan Chase", > "bankAddress": "270 Park Avenue, New York, NY 10017" > } > } > ``` > > > [!TIP] > > > > Each internal account has its own account number, and deposits are > > credited by account number. The payer doesn't need to include the > > `reference`. A deposit sent without it is still credited to this account. > > The account is held in the account owner's own legal name: the customer's > `fullName` for an individual, or `businessInfo.legalName` for a business. > A platform internal account is held in your platform's legal name. Show > that name to payers as the beneficiary name, and ask them to copy it > exactly on wires. > [accordion] EUR account funding (SEPA) > > A `EUR_ACCOUNT` returns an IBAN, and deposits reach it over SEPA: > > ```json theme={null} > { > "instructionsNotes": "Include reference in SEPA transfer description", > "accountOrWalletInfo": { > "accountType": "EUR_ACCOUNT", > "reference": "FUND-EUR789", > "iban": "DE89370400440532013000", > "swiftCode": "DEUTDEFF", > "bankName": "Deutsche Bank" > } > } > ``` > [accordion] Spark Stablecoin Funding > > For stablecoin accounts, using a Spark wallet as the funding source: > > ```json theme={null} > { > "invoice": "lnbc15u1p3xnhl2pp5jptserfk3zk4qy42tlucycrfwxhydvlemu9pqr93tuzlv9cc7g3sdqsvfhkcap3xyhx7un8cqzpgxqzjcsp5f8c52y2stc300gl6s4xswtjpc37hrnnr3c9wvtgjfuvqmpm35evq9qyyssqy4lgd8tj637qcjp05rdpxxykjenthxftej7a2zzmwrmrl70fyj9hvj0rewhzj7jfyuwkwcg9g2jpwtk3wkjtwnkdks84hsnu8xps5vsq4gj5hs", > "instructionsNotes": "Use the invoice when making Spark payment", > "accountOrWalletInfo": { > "accountType": "SPARK_WALLET", > "assetType": "USDB", > "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu" > } > } > ``` > [accordion] Solana Stablecoin Funding > > For Solana wallet accounts, using a Solana wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "SOLANA_WALLET", > "assetType": "USDC", > "address": "4Nd1m6Qkq7RfKuE5vQ9qP9Tn6H94Ueqb4xXHzsAbd8Wg" > } > } > ``` > [accordion] Ethereum Stablecoin Funding > > For Ethereum L1 wallet accounts (USDC or USDT), using an Ethereum wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "ETHEREUM_WALLET", > "assetType": "USDT", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` > [accordion] Tron Stablecoin Funding > > For Tron wallet accounts, using a Tron wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "TRON_WALLET", > "assetType": "USDT", > "address": "TNPeeaaFB7K9cmo4uQpcU32zGK8G1NYqeL" > } > } > ``` > [accordion] Plasma Stablecoin Funding > > For Plasma wallet accounts, using a Plasma wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "PLASMA_WALLET", > "assetType": "USDT", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` > [accordion] Arbitrum Stablecoin Funding > > For Arbitrum wallet accounts, using an Arbitrum wallet as the funding source (USDC or USDT): > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "ARBITRUM_WALLET", > "assetType": "USDC", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` > [accordion] Polygon Stablecoin Funding > > For Polygon wallet accounts, using a Polygon wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "POLYGON_WALLET", > "assetType": "USDC", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` > [accordion] Base Stablecoin Funding > > For Base wallet accounts, using a Base wallet as the funding source: > > ```json theme={null} > { > "accountOrWalletInfo": { > "accountType": "BASE_WALLET", > "assetType": "USDC", > "address": "0xAbCDEF1234567890aBCdEf1234567890ABcDef12" > } > } > ``` ## Checking account balances The internal account balance reflects all deposits and withdrawals. The balance includes: * **amount**: The balance amount in the smallest currency unit (cents for USD, centavos for MXN/BRL, etc.) * **currency**: Full currency details including code, name, symbol, and decimal places ### Example balance check ```bash Fetch the balance of an internal account theme={null} curl -X GET 'https://api.lightspark.com/grid/2025-10-13/customers/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001¤cy=USD' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [accordion] Response > > ```json theme={null} > { > "data": [ > { > "id": "InternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "type": "INTERNAL_FIAT", > "status": "ACTIVE", > "balance": { > "amount": 50000, > "currency": { > "code": "USD", > "name": "United States Dollar", > "symbol": "$", > "decimals": 2 > } > }, > "totalBalance": { > "amount": 50000, > "currency": { > "code": "USD", > "name": "United States Dollar", > "symbol": "$", > "decimals": 2 > } > } > } > ], > "hasMore": false, > "totalCount": 1 > } > ``` > [!TIP] > > Always check the `decimals` field in the currency object to correctly convert > between display amounts and API amounts. For example, USD has 2 decimals, so > an amount of 50000 represents \$500.00. ## Displaying funding instructions to customers When customers need to deposit funds themselves, display the funding payment instructions in your application: **1. Retrieve the internal account** Fetch the customer's internal account for their desired currency using the API. **2. Extract payment instructions** Parse the `fundingPaymentInstructions` array and select the appropriate instructions based on your customer's preferred payment method. ```javascript theme={null} const instructions = account.fundingPaymentInstructions[0]; const bankInfo = instructions.accountOrWalletInfo; ``` **3. Display instructions clearly** Show the payment details prominently in your UI: * Account holder name * Bank name and routing information (account/routing number, CLABE, PIX key, etc.) * Reference code (if provided) * Any additional notes from `instructionsNotes` > [!TIP] > > The unique banking details in each internal account automatically route > deposits to the correct destination. **4. Monitor for deposits** Set up webhook listeners to receive notifications when deposits are credited to the internal account. The account balance will update automatically. > [!NOTE] > > You'll receive `INTERNAL_ACCOUNT.BALANCE_UPDATED` webhook events when the internal account balance changes. ## Best practices > [accordion] Display complete funding instructions > > Ensure your customers have all the information needed to make deposits. Consider implementing: > > * Clear display of all banking details from `fundingPaymentInstructions` > * Copy-to-clipboard functionality for account numbers and reference codes > * Email/SMS confirmations with complete deposit instructions > [accordion] Monitor account balances proactively > > Set up monitoring to alert customers when their balance is low: > > ```javascript theme={null} > if (account.balance.amount < minimumThreshold) { > await notifyCustomer({ > type: 'LOW_BALANCE', > account: account.id, > instructions: account.fundingPaymentInstructions > }); > } > ``` > [accordion] Handle multiple currencies efficiently > > If your platform supports multiple currencies, organize internal accounts by currency in your UI: > > ```javascript theme={null} > const accountsByCurrency = accounts.data.reduce((acc, account) => { > const code = account.balance.currency.code; > acc[code] = account; > return acc; > }, {}); > > // Quick lookup: accountsByCurrency['USD'] > > ``` > [accordion] Cache account data appropriately > > Internal account details (especially funding instructions) rarely change, so you can cache them safely. However, always fetch fresh balance data before initiating transfers. ## Using internal accounts for ramps Internal accounts play a critical role in both on-ramp and off-ramp flows: ### For on-ramps (Fiat → Crypto) Internal accounts are optional for on-ramp flows using just-in-time (JIT) funding: * **JIT funding**: Quotes provide payment instructions directly; funds flow through without requiring an internal account * **Pre-funded model**: Deposit fiat to internal account first, then create and execute conversion quotes > [!TIP] > > Most on-ramp implementations use JIT funding to avoid holding customer > balances and simplify compliance. ### For off-ramps (Crypto → Fiat) Internal accounts are essential for off-ramp flows: 1. **Deposit crypto**: Transfer Bitcoin or stablecoins to the internal account 2. **Hold balance**: Crypto balance is held securely until conversion 3. **Execute conversion**: Create and execute quote to convert crypto to fiat and send to bank account > [!NOTE] > > Off-ramps require pre-funded internal accounts with crypto balances. Grid > supports Fiat, BTC, and Stablecoin deposits. ## Crypto funding for internal accounts For off-ramp operations, fund internal accounts with cryptocurrency: ### Bitcoin (Lightning Network) Internal accounts can receive Bitcoin via Lightning Network: ```json theme={null} { "fundingPaymentInstructions": [ { "accountType": "SPARK_WALLET", "assetType": "BTC", "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu", "invoice": "lnbc15u1p3xnhl2pp5jptserfk3zk4qy42tlucycrfwxhydvlemu9pqr93tuzlv9cc7g3s..." } ] } ``` **Deposit methods:** * **Spark address**: Reusable address for multiple deposits * **Lightning invoice**: Single-use invoice for specific amounts > [!NOTE] > > Lightning Network transfers are typically instant and have minimal fees, > making them ideal for crypto deposits. ### Stablecoins Internal accounts can also receive stablecoins via Lightning Network or Spark: ```json theme={null} { "fundingPaymentInstructions": [ { "accountType": "SPARK_WALLET", "assetType": "USDB", "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu" } ] } ``` > [!TIP] > > Stablecoins like USDB are 1:1 pegged to USD, offering price stability for > off-ramp balances while maintaining instant settlement via Lightning Network. ## Multi-currency balances Internal accounts support both fiat and crypto balances: ### Example: Account with USD and BTC ```json theme={null} { "data": [ { "id": "InternalAccount:usd123", "customerId": "Customer:cust001", "balance": { "amount": 100000, "currency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 } }, "totalBalance": { "amount": 100000, "currency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 } } }, { "id": "InternalAccount:btc456", "customerId": "Customer:cust001", "balance": { "amount": 10000000, "currency": { "code": "BTC", "name": "Bitcoin", "symbol": "₿", "decimals": 8 } }, "totalBalance": { "amount": 10000000, "currency": { "code": "BTC", "name": "Bitcoin", "symbol": "₿", "decimals": 8 } } } ] } ``` > [!WARNING] > > Always check the `decimals` field when working with balances. USD uses 2 > decimals (cents), while BTC uses 8 decimals (satoshis). ## Monitoring account balance changes Subscribe to `INTERNAL_ACCOUNT.BALANCE_UPDATED` webhooks to receive real-time balance updates: ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000007", "type": "INTERNAL_ACCOUNT.BALANCE_UPDATED", "timestamp": "2025-10-03T14:32:00Z", "data": { "id": "InternalAccount:btc456", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "type": "INTERNAL_CRYPTO", "status": "ACTIVE", "balance": { "amount": 10000000, "currency": { "code": "BTC", "name": "Bitcoin", "symbol": "₿", "decimals": 8 } }, "totalBalance": { "amount": 10000000, "currency": { "code": "BTC", "name": "Bitcoin", "symbol": "₿", "decimals": 8 } }, "fundingPaymentInstructions": [], "createdAt": "2025-08-01T10:00:00Z", "updatedAt": "2025-10-03T14:32:00Z" } } ``` > [!TIP] > > Balance updates are triggered by: - Incoming deposits (fiat or crypto) - Quote > executions (funds debited) - Failed transaction reversals (funds credited > back) ## Ramp-specific use cases ### Pre-funding for off-ramps Maintain crypto balances for instant fiat conversions: ```javascript theme={null} // Check Bitcoin balance before off-ramp const accounts = await getInternalAccounts(customerId); const btcAccount = accounts.data.find( (acc) => acc.balance.currency.code === "BTC" ); if (btcAccount.balance.amount >= requiredSats) { // Create quote to convert BTC to fiat const quote = await createQuote({ source: { sourceType: "ACCOUNT", accountId: btcAccount.id }, destination: { destinationType: "ACCOUNT", accountId: bankAccountId }, lockedCurrencySide: "SENDING", lockedCurrencyAmount: requiredSats, }); // Execute conversion await executeQuote(quote.id); } ``` ### Platform treasury management Use platform-level internal accounts for pooled liquidity: ```bash theme={null} # Get platform internal accounts curl -X GET 'https://api.lightspark.com/grid/2025-10-13/platform/internal-accounts?currency=BTC' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [!NOTE] > > Platform internal accounts enable centralized treasury management for > high-volume ramp operations. ## Best practices for ramps > [accordion] Choose the right funding model > > * **On-ramps**: Use JIT funding to avoid holding customer fiat > * **Off-ramps**: Pre-fund with crypto for instant fiat conversions > * **High volume**: Consider platform-level accounts for pooled liquidity > [accordion] Monitor crypto price exposure > > ```javascript theme={null} > // Track BTC value in USD > const btcBalance = account.balance.amount; // satoshis > const currentRate = await getBtcUsdRate(); > const usdValue = (btcBalance / 100000000) * currentRate; > > if (usdValue > maxExposure) { > // Convert excess BTC to stablecoin > await convertToStablecoin(btcBalance - targetBalance); > } > > ``` > [accordion] Implement balance alerts > > Set up proactive notifications for low balances: > > ```javascript theme={null} > // Alert when off-ramp liquidity is low > if (btcAccount.balance.amount < minimumSats) { > await alertTreasury({ > type: 'LOW_CRYPTO_BALANCE', > account: btcAccount.id, > balance: btcAccount.balance.amount, > minimumRequired: minimumSats > }); > } > ``` ## Next steps * [External Accounts](/ramps/accounts/external-accounts) - Configure destination accounts for conversions * [Fiat-to-Crypto Conversion](/ramps/conversion-flows/fiat-crypto-conversion) - Build conversion flows * [Webhooks](/ramps/platform-tools/webhooks) - Handle real-time notifications ## Related resources * [Platform Configuration](/ramps/onboarding/platform-configuration) - Configure supported currencies * [Sandbox Testing](/ramps/platform-tools/sandbox-testing) - Test funding and conversions safely * [API Reference](/api-reference) - Complete API documentation # Fiat-to-Crypto and Crypto-to-Fiat Source: https://agents-docs.lightspark.com/ramps/conversion-flows/fiat-crypto-conversion Build on-ramp and off-ramp flows to convert between fiat currencies and cryptocurrencies ## Overview Grid enables seamless conversion between fiat currencies and cryptocurrencies via the Lightning Network. Use quotes to lock exchange rates and get payment instructions for completing transfers. **On-ramp (Fiat → Crypto):** User sends fiat → Grid detects payment → Crypto sent to wallet **Off-ramp (Crypto → Fiat):** Execute quote → Grid processes crypto → Fiat sent to bank ## Prerequisites * Customer created in Grid * **On-ramps:** Destination crypto wallet (Spark address) + webhook endpoint * **Off-ramps:** Internal account with crypto + external bank account registered ## On-ramp: Fiat to crypto ### Create a quote First, create an external account for the crypto destination: ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", "currency": "BTC", "accountInfo": { "accountType": "SPARK_WALLET", "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu" } }' ``` Then create a quote using the external account ID: ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", "currency": "USD" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:b23dcbd6-dced-4ec4-b756-3c3a9ea3d456" }, "lockedCurrencySide": "RECEIVING", "lockedCurrencyAmount": 100000, "description": "Buy 0.001 BTC" }' ``` **Response includes payment instructions:** ```json theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000006", "status": "PENDING", "createdAt": "2025-10-03T12:00:00Z", "expiresAt": "2025-10-03T12:05:00Z", "source": { "sourceType": "REALTIME_FUNDING", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "currency": "USD" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:b23dcbd6-dced-4ec4-b756-3c3a9ea3d456" }, "sendingCurrency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 }, "receivingCurrency": { "code": "BTC", "name": "Bitcoin", "symbol": "₿", "decimals": 8 }, "totalSendingAmount": 6500, "totalReceivingAmount": 100000, "exchangeRate": 15.38, "feesIncluded": 200, "platformFeesIncluded": 0, "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000007", "paymentInstructions": [ { "accountOrWalletInfo": { "accountType": "USD_ACCOUNT", "reference": "UMA-Q12345-REF", "accountNumber": "1234567890", "routingNumber": "021000021", "bankName": "Grid Settlement Bank" } }, { "accountOrWalletInfo": { "accountType": "SOLANA_WALLET", "assetType": "USDC", "address": "4Nd1m6Qkq7RfKuE5vQ9qP9Tn6H94Ueqb4xXHzsAbd8Wg" } } ] } ``` ### Display payment instructions ```javascript theme={null} function displayPaymentInstructions(quote) { // EU customers only: an SCA-gated realtime-funding quote comes back // PENDING_AUTHORIZATION with paymentInstructions withheld until its // scaChallenge is authorized (see "Strong Customer Authentication" below). // Authorize first, then display — don't read paymentInstructions while pending. if (quote.status === "PENDING_AUTHORIZATION") { return { pendingAuthorization: true, scaChallenge: quote.scaChallenge }; } const instructions = quote.paymentInstructions[0]; return { amount: `$${(quote.totalSendingAmount / 100).toFixed(2)}`, bankName: instructions.accountOrWalletInfo.bankName, accountNumber: instructions.accountOrWalletInfo.accountNumber, routingNumber: instructions.accountOrWalletInfo.routingNumber, referenceCode: instructions.accountOrWalletInfo.reference, // User must include this expiresAt: quote.expiresAt, willReceive: `${quote.totalReceivingAmount / 100000000} BTC`, }; } ``` ### Monitor completion Grid sends `OUTGOING_PAYMENT.` webhooks as the transaction progresses: ```javascript theme={null} app.post("/webhooks/grid", async (req, res) => { const { type, data } = req.body; if (type === "OUTGOING_PAYMENT.COMPLETED") { await notifyUser(data.customerId, { message: "Your Bitcoin purchase is complete!", amount: `${data.receivedAmount.amount / 100000000} BTC`, }); } else if (type === "OUTGOING_PAYMENT.FAILED") { await notifyUser(data.customerId, { message: `Purchase failed: ${data.failureReason}`, }); // Refund webhook (OUTGOING_PAYMENT.REFUND_*) will follow } res.status(200).json({ received: true }); }); ``` See the [Transaction Lifecycle](/platform-overview/core-concepts/transaction-lifecycle) guide for all status transitions and refund handling. ## Off-ramp: Crypto to fiat ### Create and execute a quote ```javascript theme={null} // 1. Create quote const quote = await fetch("https://api.lightspark.com/grid/2025-10-13/quotes", { method: "POST", body: JSON.stringify({ source: { sourceType: "ACCOUNT", accountId: "InternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123", }, destination: { destinationType: "ACCOUNT", accountId: "ExternalAccount:e85dcbd6-dced-4ec4-b756-3c3a9ea3d965", }, lockedCurrencySide: "SENDING", lockedCurrencyAmount: 100000, // 0.001 BTC description: "Sell 0.001 BTC", }), }).then((r) => r.json()); // 2. Execute quote const result = await fetch( `https://api.lightspark.com/grid/2025-10-13/quotes/${quote.id}/execute`, { method: "POST", headers: { Authorization: `Basic ${credentials}` }, } ).then((r) => r.json()); ``` ### Track completion ```javascript theme={null} app.post("/webhooks/grid", async (req, res) => { const { type, data } = req.body; if (type === "OUTGOING_PAYMENT.COMPLETED") { await notifyUser(data.customerId, { message: "Your USD withdrawal is complete!", amount: `$${data.receivedAmount.amount / 100}`, }); } else if (type === "OUTGOING_PAYMENT.FAILED") { await notifyUser(data.customerId, { message: `Withdrawal failed: ${data.failureReason}`, }); } res.status(200).json({ received: true }); }); ``` ## Immediate execution For instant on-ramps (e.g., reward payouts), use `immediatelyExecute: true` with an internal account source: ```bash cURL 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:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:b23dcbd6-dced-4ec4-b756-3c3a9ea3d456" }, "lockedCurrencySide": "RECEIVING", "lockedCurrencyAmount": 100000, "immediatelyExecute": true }' ``` > [!NOTE] > > `immediatelyExecute` can only be used with sources that are either internal accounts or external accounts with direct pull functionality (e.g., ACH pull). ## Strong Customer Authentication (EU customers) Customers in SCA-regulated regions (in practice the EU: EUR / USDC) must confirm money movements with Strong Customer Authentication, and a conversion out of EUR / USDC is gated on its swap leg. When it applies, the quote comes back `PENDING_AUTHORIZATION` carrying an `scaChallenge` that you authorize before the conversion is released; for every other customer nothing changes. See [Per-transaction authorization](/platform-overview/sca/per-transaction-authorization) for the full walkthrough. ## Best practices > [accordion] Handle quote expiration > > ```javascript theme={null} > async function refreshQuoteIfNeeded(quote, quoteRequest) { > const expiresAt = new Date(quote.expiresAt); > const now = new Date(); > > if (expiresAt - now < 60000) { > // Less than 1 minute left > return await createNewQuote(quoteRequest); > } > > return quote; > } > ``` > [accordion] Settlement times by account type > > ```javascript theme={null} > const settlementTimes = { > USD_ACCOUNT: "1-3 business days (ACH)", > EUR_ACCOUNT: "Seconds to 1 business day (SEPA Instant / SEPA)", > BRL_ACCOUNT: "Instant (PIX)", > SWIFT_ACCOUNT: "1-3 business days", > SPARK_WALLET: "Instant", > }; > ``` > [accordion] Handle failed transactions > > ```javascript theme={null} > // quoteRequest is the request your integration sent to create the original > // quote: a transaction does not carry the parameters it was quoted from. > async function handleFailedTransaction(transaction, quoteRequest) { > if (transaction.status !== "FAILED") return; > > await notifyUser(transaction.customerId, { > message: "Transaction failed", > reason: transaction.failureReason, > action: "retry", > }); > > if ( > transaction.failureReason === "QUOTE_EXPIRED" || > transaction.failureReason === "QUOTE_RATE_UNAVAILABLE" > ) { > await createNewQuote(quoteRequest); > } > } > ``` ## Next steps ![](/images/icons/wallet1.svg) Self-custody wallets Send crypto to user-controlled wallets [Open](/ramps/conversion-flows/self-custody-wallets) ![](/images/icons/bell.svg) Webhooks Monitor transaction status [Open](/ramps/platform-tools/webhooks) ![](/images/icons/code.svg) API Reference Complete API documentation [Open](/api-reference) # Self-Custody Wallet Integration Source: https://agents-docs.lightspark.com/ramps/conversion-flows/self-custody-wallets Send and receive cryptocurrency to and from user-controlled wallets ## Overview Grid supports sending Bitcoin via Lightning Network to self-custody wallets using Spark wallet addresses. This enables users to maintain full control of their crypto while benefiting from Grid's fiat-to-crypto conversion and payment rails. > [!NOTE] > > Spark wallets use the Lightning Network for instant, low-cost Bitcoin > transactions. Users can receive payments directly to their self-custody > wallets without Grid holding their funds. ## How it works 1. **User provides wallet address** - Get Spark wallet address from user 2. **Create quote** - Generate quote for fiat-to-crypto or crypto-to-crypto transfer 3. **Execute transfer** - Send crypto directly to user's wallet 4. **Instant settlement** - Lightning Network provides near-instant confirmation ## Prerequisites * Customer created in Grid * Valid Spark wallet address from user * Webhook endpoint for payment notifications (optional, for status updates) ## Sending crypto to self-custody wallets ### Step 1: Collect wallet address Request the user's Spark wallet address. Spark addresses start with `spark1`: ```javascript theme={null} function validateSparkAddress(address) { // Spark addresses start with spark1 and are typically 90+ characters if (!address.startsWith("spark1")) { throw new Error("Invalid Spark wallet address"); } if (address.length < 90) { throw new Error("Spark address appears incomplete"); } return address; } // Example valid address const sparkAddress = "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu"; ``` > [!WARNING] > > Always validate wallet addresses before creating quotes. Invalid addresses > will cause transaction failures and potential fund loss. ### Step 2: Create external account for the wallet Register the Spark wallet as an external account: ```bash cURL 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:019542f5-b3e7-1d02-0000-000000000001", "currency": "BTC", "accountInfo": { "accountType": "SPARK_WALLET", "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu" } }' ``` ```javascript Node.js theme={null} const externalAccount = await fetch( "https://api.lightspark.com/grid/2025-10-13/customers/external-accounts", { method: "POST", headers: { Authorization: `Basic ${credentials}`, "Content-Type": "application/json", }, body: JSON.stringify({ customerId: "Customer:019542f5-b3e7-1d02-0000-000000000001", currency: "BTC", accountInfo: { accountType: "SPARK_WALLET", address: sparkAddress, }, }), } ).then((r) => r.json()); console.log("External account ID:", externalAccount.id); ``` ```python Python theme={null} import requests import base64 credentials = base64.b64encode( f"{api_token_id}:{api_secret}".encode() ).decode() response = requests.post( 'https://api.lightspark.com/grid/2025-10-13/customers/external-accounts', headers={ 'Authorization': f'Basic {credentials}', 'Content-Type': 'application/json' }, json={ 'customerId': 'Customer:019542f5-b3e7-1d02-0000-000000000001', 'currency': 'BTC', 'accountInfo': { 'accountType': 'SPARK_WALLET', 'address': spark_address } } ) external_account = response.json() print(f"External account ID: {external_account['id']}") ``` > [!TIP] > > Store the external account ID for future transfers. Users can reuse the same > wallet address for multiple transactions. ### Step 3: Create and execute a quote #### Option A: From fiat to self-custody wallet Convert fiat directly to crypto in user's wallet: ```javascript theme={null} // Create quote for fiat-to-crypto const quote = await fetch("https://api.lightspark.com/grid/2025-10-13/quotes", { method: "POST", body: JSON.stringify({ source: { sourceType: "REALTIME_FUNDING", customerId: "Customer:019542f5-b3e7-1d02-0000-000000000001", currency: "USD", }, destination: { destinationType: "ACCOUNT", accountId: externalAccount.id, }, lockedCurrencySide: "SENDING", lockedCurrencyAmount: 10000, // $100.00 description: "Buy Bitcoin to self-custody wallet", }), }).then((r) => r.json()); // Display payment instructions to user console.log("Send fiat to:", quote.paymentInstructions); console.log("Will receive:", `${quote.totalReceivingAmount / 100000000} BTC`); ``` #### Option B: From internal account to self-custody wallet Transfer crypto from internal account to user's wallet: ```javascript theme={null} // Same-currency transfer: immediatelyExecute creates and executes in one request const quote = await fetch("https://api.lightspark.com/grid/2025-10-13/quotes", { method: "POST", body: JSON.stringify({ source: { sourceType: "ACCOUNT", accountId: "InternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123", }, destination: { destinationType: "ACCOUNT", accountId: externalAccount.id, }, lockedCurrencySide: "SENDING", lockedCurrencyAmount: 100000, // 0.001 BTC in satoshis immediatelyExecute: true, }), }).then((r) => r.json()); console.log("Transfer initiated:", quote.transactionId); ``` ### Step 4: Monitor transfer completion Track the transfer status via `OUTGOING_PAYMENT.` webhooks: ```javascript theme={null} app.post("/webhooks/grid", async (req, res) => { const { type, data } = req.body; switch (type) { case "OUTGOING_PAYMENT.COMPLETED": await notifyUser(data.customerId, { message: "Bitcoin sent to your wallet!", amount: `${data.receivedAmount.amount / 100000000} BTC`, }); break; case "OUTGOING_PAYMENT.FAILED": await notifyUser(data.customerId, { message: "Transfer failed", reason: data.failureReason, action: "Please verify your wallet address", }); // Refund webhook (OUTGOING_PAYMENT.REFUND_*) will follow break; case "OUTGOING_PAYMENT.REFUND_COMPLETED": await notifyUser(data.customerId, { message: "Refund completed. Funds returned to your account.", }); break; } res.status(200).json({ received: true }); }); ``` See the [Transaction Lifecycle](/platform-overview/core-concepts/transaction-lifecycle) guide for all status transitions and refund handling. ## Best practices > [accordion] Validate wallet addresses > > Always validate Spark addresses before processing: > > ```javascript theme={null} > function validateSparkAddress(address) { > // Check format > if (!address.startsWith("spark1")) { > return { valid: false, error: "Must start with spark1" }; > } > > // Check length (typical Spark addresses are 90+ chars) > if (address.length < 90) { > return { valid: false, error: "Address too short" }; > } > > // Check for common typos > if (address.includes(" ") || address.includes("\n")) { > return { valid: false, error: "Address contains whitespace" }; > } > > return { valid: true }; > } > ``` > [accordion] Handle Lightning Network specifics > > Lightning Network has unique characteristics: > > ```javascript theme={null} > const lightningLimits = { > minAmount: 1000, // 0.00001 BTC (1000 satoshis) > maxAmount: 10000000, // 0.1 BTC (10M satoshis) > settlementTime: "Instant (typically < 10 seconds)", > fees: "Very low (typically < 1%)", > }; > > function validateLightningAmount(satoshis) { > if (satoshis < lightningLimits.minAmount) { > throw new Error(`Minimum amount is ${lightningLimits.minAmount} sats`); > } > > if (satoshis > lightningLimits.maxAmount) { > throw new Error(`Maximum amount is ${lightningLimits.maxAmount} sats`); > } > > return true; > } > ``` > [accordion] Provide clear user instructions > > Help users understand the process: > > ```javascript theme={null} > function getWalletInstructions(walletType) { > return { > SPARK_WALLET: { > title: "Lightning Network Wallet", > steps: [ > "Open your Lightning wallet app", > 'Select "Send" or "Pay"', > "Scan the QR code or paste the address", > "Confirm the amount and send", > "Funds arrive instantly", > ], > compatibleWallets: [ > "Spark Wallet", > "Phoenix", > "Breez", > "Muun", > "Blue Wallet (Lightning)", > ], > }, > }; > } > ``` > [accordion] Handle failed transfers > > Implement retry logic for common failures: > > ```javascript theme={null} > async function handleTransferFailure(transaction) { > const { failureReason } = transaction; > > const retryableReasons = [ > "TEMPORARY_NETWORK_ERROR", > "INSUFFICIENT_LIQUIDITY", > "ROUTE_NOT_FOUND", > ]; > > if (retryableReasons.includes(failureReason)) { > // Retry after delay > await delay(5000); > return await retryTransfer(transaction.quoteId); > } > > // Non-retryable errors > const errorMessages = { > INVALID_ADDRESS: > "The wallet address is invalid. Please verify and try again.", > AMOUNT_TOO_SMALL: > "Amount is below minimum. Lightning Network requires at least 1000 satoshis.", > AMOUNT_TOO_LARGE: > "Amount exceeds Lightning Network limits. Consider splitting into multiple transfers.", > }; > > return { > canRetry: false, > message: > errorMessages[failureReason] || > "Transfer failed. Please contact support.", > }; > } > ``` ## Testing in sandbox Test self-custody wallet flows in sandbox mode: ```bash theme={null} # Step 1: Create the external account for the Spark wallet 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:...", "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 "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H 'Content-Type: application/json' \ -d '{ "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:..." }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:..." }, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 10000, "immediatelyExecute": true }' ``` > [!TIP] > > In sandbox mode, transfers to Spark wallets complete instantly without > requiring actual Lightning Network transactions. ## Next steps ![](/images/icons/arrow-left-right.svg) Fiat-crypto conversion Learn about on-ramp and off-ramp flows [Open](/ramps/conversion-flows/fiat-crypto-conversion) ![](/images/icons/bank.svg) External accounts Manage external accounts including Spark wallets [Open](/ramps/accounts/external-accounts) ![](/images/icons/bell.svg) Webhooks Set up webhooks to monitor transaction status [Open](/ramps/platform-tools/webhooks) ![](/images/icons/sandbox.svg) Sandbox testing Test wallet integrations in sandbox mode [Open](/ramps/platform-tools/sandbox-testing) # Authentication Source: https://agents-docs.lightspark.com/ramps/global-accounts/authentication Register, reauthenticate, and manage Global Account credentials (passkey, OAuth, email OTP) Every Global Account action beyond receiving funds must be authorized by a session signing key. Sessions are issued by verifying one of four **credential types** on the account: | Type | When to use it | | - | - | | **`PASSKEY`** | Best default. Biometric, phishing-resistant, usable across the user's devices via iCloud Keychain / Google Password Manager. | | **`OAUTH`** | Your platform already authenticates the user via OIDC (Google, Apple, your own IdP) and you want Grid to trust the same identity. | | **`EMAIL_OTP`** | Lowest-friction option. Works on any device with email access — no biometric hardware, identity provider, or client SDK required beyond the code entry field. | | **`SMS_OTP`** | Same flow as `EMAIL_OTP`, but delivered to the user's phone number instead of email. Useful when phone is the primary contact channel. | A single internal account can hold one `EMAIL_OTP` credential, one `SMS_OTP` credential, and multiple distinct `PASSKEY` credentials concurrently. `OAUTH` credentials can be added for each supported provider identity. ## Registration vs. verification Global Accounts are initialized with an `EMAIL_OTP` credential tied to the customer email on the internal account. Use that credential to mint the first session, or use an already-registered `OAUTH` or `PASSKEY` credential after you add one. To produce a session: * **`EMAIL_OTP`** — if you need a fresh OTP or encryption target, first call **`POST /auth/credentials/{id}/challenge`**. Generate a P-256 client key pair, HPKE-encrypt the OTP value plus the public key into `encryptedOtpBundle`, then call **`POST /auth/credentials/{id}/verify`**. Grid returns a `202` signed-retry challenge; stamp its `payloadToSign` with the same private key and retry with `Grid-Wallet-Signature` plus `Request-Id`. The signed retry returns the session; the private key you generated is the session signing key. * **`OAUTH`** — call **`POST /auth/credentials/{id}/verify`** with a fresh OIDC token plus `clientPublicKey`. The response is the session; the private key matching `clientPublicKey` is the session signing key. * **`PASSKEY`** — call **`POST /auth/credentials/{id}/challenge`** with your `clientPublicKey` to receive a Grid-issued `challenge` and `requestId`, UTF-8 encode the challenge string for `navigator.credentials.get()`, then call **`POST /auth/credentials/{id}/verify`** with the resulting assertion and the `Request-Id` header. Grid bakes the `clientPublicKey` from the `/challenge` call into the session-creation payload, so it does **not** appear on the `/verify` body. To add another credential, call **`POST /auth/credentials`** with the new credential details. Because the account already has an `EMAIL_OTP` credential, Grid returns a `202` signed-retry challenge. Stamp that `payloadToSign` with an active session signing key, then retry the same request with `Grid-Wallet-Signature` and `Request-Id`. The signed retry returns the new `AuthMethod`. Re-authentication after a session expires skips the original `POST /auth/credentials` create call but otherwise follows the same per-type pattern. `EMAIL_OTP` re-auth needs a fresh OTP email, so call `POST /auth/credentials/{id}/challenge` first; `PASSKEY` re-auth uses the same `/challenge` (with `clientPublicKey`) → `/verify` two-step as the first authentication; `OAUTH` is the exception — since proof of control is a fresh OIDC token, there's nothing to pre-issue, so `/verify` alone suffices. ## Passkey ### Passkey registration Passkey registration spans four parties: the **client** (browser or app), your **integrator backend**, **Grid**, and the platform authenticator (Touch ID / Face ID / Windows Hello / a security key). Your backend issues the WebAuthn registration challenge. Registering the passkey on Grid is an additional-credential signed-retry flow, authorized by an active session from an existing credential. The first authentication challenge is requested explicitly via `POST /auth/credentials/{id}/challenge` — same call shape used for every subsequent reauthentication. ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid participant A as Authenticator C->>IB: Start registration IB->>IB: Generate random challenge (stored in session) IB-->>C: { challenge, rpId, user } C->>A: navigator.credentials.create({ challenge, … }) A-->>C: attestation (credentialId, clientDataJson, attestationObject) C->>IB: POST /my-backend/passkey/register (attestation) IB->>G: POST /auth/credentials { type: PASSKEY, challenge, attestation, … } G-->>IB: 202 { payloadToSign, requestId, expiresAt } IB-->>C: { payloadToSign, requestId } C->>C: stamp(payloadToSign, existingSessionPrivateKey) C->>IB: { stamp, requestId } IB->>G: Same POST
            Grid-Wallet-Signature: stamp
            Request-Id: requestId G-->>IB: 201 AuthMethod { id, type, accountId, … } IB-->>C: { credentialId } C->>C: generateClientKeyPair() C->>IB: POST /my-backend/passkey/challenge (clientPublicKey) IB->>G: POST /auth/credentials/{id}/challenge
            { clientPublicKey } G-->>IB: 200 PasskeyAuthChallenge { challenge, requestId, expiresAt } IB-->>C: { challenge, requestId } C->>A: navigator.credentials.get({ challenge, … }) A-->>C: assertion (credentialId, clientDataJson, authenticatorData, signature) C->>IB: POST /my-backend/passkey/verify (assertion, requestId) IB->>G: POST /auth/credentials/{id}/verify
            Request-Id: requestId
            { type: PASSKEY, assertion } G-->>IB: 200 AuthSession { id, expiresAt } IB-->>C: { sessionId, expiresAt } C->>C: hold the client private key as the session signing key ``` The `challenge` on `POST /auth/credentials` is the one your backend issued for WebAuthn registration. The signed-retry `payloadToSign` is the registration payload that the customer's current session authorizes. The challenge used for the WebAuthn assertion comes later from `POST /auth/credentials/{id}/challenge` — Grid bakes the `clientPublicKey` you send there into the session-creation payload, so the resulting session belongs to the key the device generated. Because that key plumbing happens on `/challenge`, the subsequent `/verify` call carries only the assertion (plus the `Request-Id` header). > [!TIP] > > Passkeys are domain-bound. Before shipping, set up your `/.well-known/apple-app-site-association` and `/.well-known/assetlinks.json` entries so the platform authenticator binds the passkey to your origin and app bundles: > > * Web: [passkeys.dev bootstrapping guide](https://passkeys.dev/docs/use-cases/bootstrapping/#opting-the-user-into-passkeys) > * Android: [Create passkeys on Android](https://developer.android.com/identity/passkeys/create-passkeys) > * iOS: [Supporting passkeys](https://developer.apple.com/documentation/authenticationservices/supporting-passkeys) #### Client sample code The client never talks to Grid. It talks to your integrator backend — the snippets below simulate that call with `fetch('/my-backend/...')`, which your backend then relays to Grid. ```typescript Web (TypeScript) theme={null} // 1. Ask your backend for a registration challenge. const startRes = await fetch("/my-backend/passkey/register/start", { method: "POST", credentials: "include", }); const { challenge, rpId, user } = await startRes.json(); // 2. Ask the authenticator to create a passkey. const attestation = (await navigator.credentials.create({ publicKey: { challenge: base64urlToBytes(challenge), rp: { id: rpId, name: "Acme Wallet" }, user: { id: base64urlToBytes(user.id), name: user.email, displayName: user.displayName, }, pubKeyCredParams: [{ type: "public-key", alg: -7 }], // ES256 authenticatorSelection: { residentKey: "required", userVerification: "required" }, timeout: 60_000, }, })) as PublicKeyCredential; const att = attestation.response as AuthenticatorAttestationResponse; // 3. Send the attestation to your backend, which starts POST /auth/credentials. const registerRes = await fetch("/my-backend/passkey/register", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ nickname: "This device", credentialId: bytesToBase64url(new Uint8Array(attestation.rawId)), clientDataJson: bytesToBase64url(new Uint8Array(att.clientDataJSON)), attestationObject: bytesToBase64url(new Uint8Array(att.attestationObject)), transports: att.getTransports?.() ?? [], }), }); const { payloadToSign, requestId: registrationRequestId } = await registerRes.json(); // 4. Stamp the registration payload with an existing session signing key, then // complete the signed retry. The backend retries the same Grid request with // Grid-Wallet-Signature and Request-Id. const gridWalletSignature = await buildGridWalletSignature( existingSessionPrivateKeyBytes, payloadToSign, ); const registerCompleteRes = await fetch("/my-backend/passkey/register/complete", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ requestId: registrationRequestId, gridWalletSignature }), }); const { credentialId } = await registerCompleteRes.json(); // 5. Generate the client key pair and ask Grid for an authentication challenge // bound to its public key. const { privateKey, publicKeyHex } = generateClientKeyPair(); const challengeRes = await fetch("/my-backend/passkey/challenge", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ credentialId, clientPublicKey: publicKeyHex }), }); const { challenge: gridChallenge, requestId: authRequestId } = await challengeRes.json(); // 6. Run the WebAuthn assertion against the Grid-issued challenge. This // challenge is a lowercase hex string; UTF-8 encode it exactly as returned. const assertion = (await navigator.credentials.get({ publicKey: { challenge: new TextEncoder().encode(gridChallenge), rpId, userVerification: "required", allowCredentials: [{ type: "public-key", id: base64urlToBytes(credentialId) }], }, })) as PublicKeyCredential; const asr = assertion.response as AuthenticatorAssertionResponse; // 7. Send the assertion to your backend; it relays to POST /verify with Request-Id. const verifyRes = await fetch("/my-backend/passkey/verify", { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ credentialId, requestId: authRequestId, assertion: { credentialId: bytesToBase64url(new Uint8Array(assertion.rawId)), clientDataJson: bytesToBase64url(new Uint8Array(asr.clientDataJSON)), authenticatorData: bytesToBase64url(new Uint8Array(asr.authenticatorData)), signature: bytesToBase64url(new Uint8Array(asr.signature)), userHandle: asr.userHandle ? bytesToBase64url(new Uint8Array(asr.userHandle)) : null, }, }), }); const { sessionId, expiresAt } = await verifyRes.json(); // privateKey is now the session signing key until expiresAt — see client-keys.mdx. ``` ```kotlin Android (Kotlin) theme={null} // Uses Jetpack Credential Manager. // implementation("androidx.credentials:credentials:1.3.0") // implementation("androidx.credentials:credentials-play-services-auth:1.3.0") import androidx.credentials.CreatePublicKeyCredentialRequest import androidx.credentials.CredentialManager import androidx.credentials.GetCredentialRequest import androidx.credentials.GetPublicKeyCredentialOption import androidx.credentials.PublicKeyCredential suspend fun registerPasskey(context: Context, api: MyBackendApi): EmbeddedWalletSession { // 1. Backend → WebAuthn registration options JSON (challenge, rp, user, ...). val createOptionsJson = api.passkeyRegisterStart() val cm = CredentialManager.create(context) // 2. Authenticator creates the passkey. val createResp = cm.createCredential( context = context, request = CreatePublicKeyCredentialRequest(createOptionsJson), ) as androidx.credentials.CreatePublicKeyCredentialResponse val attestationJson = createResp.registrationResponseJson // 3. Backend -> POST /auth/credentials. Returns a signed-retry challenge. val registerChallenge = api.passkeyRegister(attestationJson, nickname = "This device") // 4. Client stamps the registration payload with an existing session key. val gridWalletSignature = buildGridWalletSignature( existingSessionKeys, registerChallenge.payloadToSign, ) val registerResp = api.passkeyRegisterComplete( requestId = registerChallenge.requestId, gridWalletSignature = gridWalletSignature, ) // 5. Generate client key pair, then ask Grid for an authentication challenge // bound to that public key via POST /auth/credentials/{id}/challenge. val clientKeys = generateClientKeyPair(alias = "embedded-wallet-${registerResp.credentialId}") val challengeResp = api.passkeyChallenge( credentialId = registerResp.credentialId, clientPublicKeyHex = clientKeys.publicKeyHex, ) // 6. Run WebAuthn assertion against Grid-issued challenge. The returned // value is a lowercase hex string; UTF-8 encode it to bytes, then put // those bytes into the platform WebAuthn request format. val webAuthnChallenge = base64UrlEncode( challengeResp.challenge.toByteArray(Charsets.UTF_8), ) val getOptionsJson = buildWebAuthnGetOptionsJson( challenge = webAuthnChallenge, rpId = registerResp.rpId, credentialId = registerResp.credentialId, ) val getResp = cm.getCredential( context = context, request = GetCredentialRequest(listOf(GetPublicKeyCredentialOption(getOptionsJson))), ).credential as PublicKeyCredential val assertionJson = getResp.authenticationResponseJson // 7. Backend -> POST /verify with Request-Id. Returns the AuthSession. return api.passkeyVerify( credentialId = registerResp.credentialId, requestId = challengeResp.requestId, assertionJson = assertionJson, ) } ``` ```swift iOS (Swift) theme={null} import AuthenticationServices final class PasskeyCoordinator: NSObject, ASAuthorizationControllerDelegate { private let api: MyBackendApi private var continuation: CheckedContinuation? private var clientKeys: ClientKeyPair? private var pendingCredentialId: String? private var pendingRequestId: String? init(api: MyBackendApi) { self.api = api } func registerPasskey() async throws -> EmbeddedWalletSession { // 1. Backend → WebAuthn registration options. let options = try await api.passkeyRegisterStart() // 2. Authenticator creates the passkey. let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( relyingPartyIdentifier: options.rpId, ) let request = provider.createCredentialRegistrationRequest( challenge: options.challenge, name: options.user.name, userID: options.user.id, ) let attestation = try await withCheckedThrowingContinuation { (cont: CheckedContinuation< ASAuthorizationPlatformPublicKeyCredentialRegistration, Error >) in let controller = ASAuthorizationController(authorizationRequests: [request]) controller.delegate = AttestationDelegate(cont: cont) controller.performRequests() } // 3. Backend -> POST /auth/credentials. Returns a signed-retry challenge. let registerChallenge = try await api.passkeyRegister( credentialId: attestation.credentialID.base64URLEncoded, clientDataJson: attestation.rawClientDataJSON.base64URLEncoded, attestationObject: attestation.rawAttestationObject!.base64URLEncoded, nickname: "This device", ) // 4. Client stamps the registration payload with an existing session key. let gridWalletSignature = try buildGridWalletSignature( signingKey: existingSessionSigningKey, payloadToSign: registerChallenge.payloadToSign, ) let registered = try await api.passkeyRegisterComplete( requestId: registerChallenge.requestId, gridWalletSignature: gridWalletSignature, ) // 5. Generate client key pair, ask Grid for an authentication challenge // bound to that public key via POST /auth/credentials/{id}/challenge. let keys = generateClientKeyPair() let challengeResp = try await api.passkeyChallenge( credentialId: registered.credentialId, clientPublicKeyHex: keys.publicKeyHex, ) // 6. Run WebAuthn assertion against Grid-issued challenge. The returned // value is a lowercase hex string; UTF-8 encode it as challenge bytes. let assertRequest = provider.createCredentialAssertionRequest( challenge: Data(challengeResp.challenge.utf8), ) assertRequest.allowedCredentials = [ ASAuthorizationPlatformPublicKeyCredentialDescriptor( credentialID: Data(base64URLEncoded: registered.credentialId)!, ), ] let assertion = try await withCheckedThrowingContinuation { (cont: CheckedContinuation< ASAuthorizationPlatformPublicKeyCredentialAssertion, Error >) in let controller = ASAuthorizationController(authorizationRequests: [assertRequest]) controller.delegate = AssertionDelegate(cont: cont) controller.performRequests() } // 7. Backend -> POST /verify with Request-Id. Returns the AuthSession. return try await api.passkeyVerify( credentialId: registered.credentialId, requestId: challengeResp.requestId, clientDataJson: assertion.rawClientDataJSON.base64URLEncoded, authenticatorData: assertion.rawAuthenticatorData.base64URLEncoded, signature: assertion.signature.base64URLEncoded, userHandle: assertion.userID?.base64URLEncoded, ) } } ``` #### WebAuthn → Grid parameter map These are the fields you need to pass through on each hop. **Registration (`/auth/credentials`):** | Browser (`credential`) | Your backend payload | Grid request body | | - | - | - | | `credential.rawId` | `credentialId` | `attestation.credentialId` | | `response.clientDataJSON` | `clientDataJson` | `attestation.clientDataJson` | | `response.attestationObject` | `attestationObject` | `attestation.attestationObject` | | `response.getTransports()` | `transports` | `attestation.transports` | | *(backend session state)* | `challenge` | `challenge` *(top-level, not under `attestation`)* | | *(backend-chosen)* | `nickname` | `nickname` | | *(Grid account id)* | — | `accountId` | **Authentication challenge (`/auth/credentials/{id}/challenge`):** | Source | Your backend payload | Grid request body | | - | - | - | | *(client-generated)* | `clientPublicKey` | `clientPublicKey` | **Assertion (`/auth/credentials/{id}/verify`):** | Browser (`credential`) | Your backend payload | Grid request body | | - | - | - | | `credential.rawId` | `credentialId` | `assertion.credentialId` | | `response.clientDataJSON` | `clientDataJson` | `assertion.clientDataJson` | | `response.authenticatorData` | `authenticatorData` | `assertion.authenticatorData` | | `response.signature` | `signature` | `assertion.signature` | | `response.userHandle` | `userHandle` | `assertion.userHandle` *(optional)* | | *(from `/challenge` response)* | `requestId` | `Request-Id` header | > [!NOTE] > > The WebAuthn spec names the field `clientDataJSON`. Grid spells it `clientDataJson` (lowercase `json`) for consistency with the rest of the API. The **bytes are identical** — only the field name changes. ### Passkey reauthentication When a session expires the client re-verifies without recreating the credential. Reauthentication uses the same `/challenge` → `/verify` shape as the first authentication: generate a fresh client key pair, call `POST /auth/credentials/{id}/challenge` with the new `clientPublicKey`, UTF-8 encode the returned challenge string for `navigator.credentials.get()`, then call `/verify` with the assertion and the matching `Request-Id` header. ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid participant A as Authenticator C->>IB: session expired, re-authenticate C->>C: generateClientKeyPair() C->>IB: POST /my-backend/passkey/challenge (clientPublicKey) IB->>G: POST /auth/credentials/{id}/challenge
            { clientPublicKey } G-->>IB: 200 PasskeyAuthChallenge { challenge, requestId, expiresAt } IB-->>C: { challenge, requestId } C->>A: navigator.credentials.get({ challenge }) A-->>C: assertion C->>IB: POST /my-backend/passkey/reauth (assertion, requestId) IB->>G: POST /auth/credentials/{id}/verify
            Request-Id: requestId
            { type: PASSKEY, assertion } G-->>IB: 200 AuthSession { id, expiresAt } IB-->>C: { sessionId, expiresAt } ``` ## OAuth (OIDC) Use an OAuth credential when your platform already authenticates the user with an OpenID Connect identity provider (Google, Apple, your own IdP) and you want Grid to trust that same identity. Adding OAuth to an initialized Global Account requires an active session from an existing credential and uses the same signed-retry pattern as passkey registration. ### OAuth registration ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid participant OP as OIDC provider C->>OP: Sign-in flow OP-->>C: id_token (OIDC) C->>IB: POST /my-backend/oauth/register { oidcToken } IB->>G: POST /auth/credentials { type: OAUTH, oidcToken, accountId } G-->>IB: 202 { payloadToSign, requestId, expiresAt } IB-->>C: { payloadToSign, requestId } C->>C: stamp(payloadToSign, existingSessionPrivateKey) C->>IB: { stamp, requestId } IB->>G: Same POST
            Grid-Wallet-Signature: stamp
            Request-Id: requestId G->>OP: fetch .well-known/openid-configuration + jwks G-->>IB: 201 AuthMethod IB-->>C: { credentialId } C->>C: generateClientKeyPair() C->>OP: get fresh id_token (iat within 60s) OP-->>C: id_token C->>IB: POST /my-backend/oauth/verify { oidcToken, clientPublicKey } IB->>G: POST /auth/credentials/{id}/verify { type: OAUTH, oidcToken, clientPublicKey } G-->>IB: 200 AuthSession IB-->>C: { sessionId, expiresAt } ``` Grid validates the OIDC token signature against the issuer's JWKS on every call and requires `iat` to be no more than **60 seconds** older than the request. Use a fresh token for each `verify` call; cached tokens will fail. The token identity (`iss`, `aud`, and `sub`) must match the OAuth credential being verified. > [!NOTE] > > In sandbox, OAuth still uses JWT-shaped OIDC tokens. The sandbox skips real IdP signature verification, but it validates the same identity and freshness claims. For `verify`, include `nonce` equal to the lowercase hex SHA-256 digest of the `clientPublicKey` string exactly as sent: the compressed key in lowercase hex. See [Sandbox testing](/global-accounts/platform-tools/sandbox-testing#oauth-oidc-token). ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "OAUTH", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9..." }' ``` **Response on the signed retry:** `201 AuthMethod` with `nickname` populated from the OIDC token's `email` claim. ### OAuth verify / reauthentication `POST /auth/credentials/{id}/verify` is also the reauthentication path — call it with a fresh OIDC token whenever the session expires. ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "OAUTH", "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9...", "clientPublicKey": "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" }' ``` ## Email OTP The lowest-friction credential type — works on any device with email access and requires no biometric hardware, identity provider, or client-side setup beyond an input field for the code. Global Accounts are initialized with an `EMAIL_OTP` credential; call `POST /auth/credentials` for `EMAIL_OTP` only if the credential was removed and you need to add it back. ### Default Email OTP credential Grid creates the first `EMAIL_OTP` credential when the Global Account is provisioned. The credential uses the customer email on file for the internal account. To authenticate with it, send an OTP challenge, then verify using the secure encrypted OTP flow. The client never sends the plaintext OTP code. Instead, it HPKE-encrypts the code (together with a fresh public key) to an enclave bundle returned from the challenge. The server is a pass-through and never sees the plaintext. ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid participant E as Email C->>IB: POST /my-backend/otp/challenge { credentialId } IB->>G: POST /auth/credentials/{id}/challenge G->>E: deliver OTP email (to customer email on file) G-->>IB: 200 AuthMethod + otpEncryptionTargetBundle IB-->>C: { otpEncryptionTargetBundle } E-->>C: OTP code C->>C: generateClientKeyPair() (TEK) C->>C: HPKE-encrypt { otp_code, public_key } → encryptedOtpBundle C->>IB: POST /my-backend/otp/verify { encryptedOtpBundle } IB->>G: POST /auth/credentials/{id}/verify { type: EMAIL_OTP, encryptedOtpBundle } G-->>IB: 202 { payloadToSign, requestId } IB-->>C: { payloadToSign, requestId } C->>C: sign(payloadToSign, tekPrivateKey) C->>IB: POST /my-backend/otp/verify/complete { stamp, requestId } IB->>G: Same POST + Grid-Wallet-Signature + Request-Id G-->>IB: 200 AuthSession IB-->>C: { expiresAt } ``` ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/challenge" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (200):** ```json theme={null} { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000004", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "otpEncryptionTargetBundle": "{\"version\":\"v1.0.0\",\"data\":\"7b22...\",\"dataSignature\":\"3045...\",\"enclaveQuorumPublic\":\"04a1...\"}", "createdAt": "2026-04-19T12:00:00Z", "updatedAt": "2026-04-19T12:00:00Z" } ``` The client generates a fresh P-256 key pair (the TEK — Target Encryption Key), HPKE-encrypts `{otp_code, public_key}` under `otpEncryptionTargetBundle`, and submits the encrypted payload. `public_key` must be the compressed public key (`02` or `03` prefix). See [Encrypt the OTP code](client-keys#encrypt-the-otp-code-email_otp-only) for implementation details. Then verify with the encrypted OTP bundle: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "EMAIL_OTP", "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" }' ``` **Response (202):** ```json theme={null} { "type": "EMAIL_OTP", "payloadToSign": "{\"login\":{\"publicKey\":\"02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31\"},\"tokenId\":\"7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21\",\"type\":\"USAGE_TYPE_LOGIN\"}", "requestId": "Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21", "expiresAt": "2026-04-19T12:05:00Z" } ``` The client stamps the exact UTF-8 bytes of `payloadToSign`, unchanged, with the TEK private key (the same key whose public key was encrypted in the bundle), then retries the same request body with the stamp: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ -d '{ "type": "EMAIL_OTP", "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" }' ``` **Response (200):** ```json theme={null} { "id": "Session:019542f5-b3e7-1d02-0000-000000000003", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-19T12:00:01Z", "updatedAt": "2026-04-19T12:00:01Z", "expiresAt": "2026-04-19T12:15:01Z" } ``` The TEK public key becomes the session API key. The response carries no key material: the client already holds the session signing key (the TEK private key it generated). `verify` shares its terminal status code with the still-processing response: a `200` here is only a session if the body isn't `{ "status": "PROCESSING" }`. See [handling a still-processing response](#handling-a-still-processing-response) — if you get `PROCESSING`, re-send the identical signed retry (same `encryptedOtpBundle`, `Grid-Wallet-Signature`, and `Request-Id`) until you get the session shown above. > [!NOTE] > > **In sandbox, the OTP code is always `000000`** — encrypt that value in the bundle. The sandbox runs real HPKE end-to-end; the only shortcut is skipping email delivery. See [Client keys](client-keys#encrypt-the-otp-code-email_otp-only) for the encryption flow. ### Resending an OTP If the code expires or the email didn't arrive, re-issue the challenge with `POST /auth/credentials/{id}/challenge`. This sends a fresh OTP email and leaves the `AuthMethod` otherwise untouched. `challenge` can also return a [still-processing `200`](#handling-a-still-processing-response) while the resend settles at the wallet provider — re-request the challenge until you get the `AuthMethod` back rather than a fresh OTP being sent twice. ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000004/challenge" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` > [!WARNING] > > Challenge re-issues are rate-limited. On `429`, back off for the duration returned in the `Retry-After` header before retrying. ### Email OTP reauthentication Same pattern as the first activation: call `/challenge` to send a new OTP and receive a fresh `otpEncryptionTargetBundle`, generate a new TEK key pair, build the `encryptedOtpBundle`, and complete the two-step verify flow. ### Changing the sign-in email or phone number The `EMAIL_OTP` address is the customer email on file, and the `SMS_OTP` number is the customer phone number. The customer must prove they control a new address or number before it replaces the one they sign in with. Changing it takes four calls, and the same steps apply to `phoneNumber` with `SMS_OTP`. **1. Register the new address** Call `POST /auth/credentials` with the new address. Grid sends a code to it and returns the replacement credential with an `otpEncryptionTargetBundle`. The current credential keeps working until the change lands. ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "EMAIL_OTP", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "email": "jane.new@example.com" }' ``` **2. Verify the code** Encrypt the code the customer received to that `otpEncryptionTargetBundle`, as for a login, and send it to `POST /auth/credentials/{id}/verify` with the replacement's id. Grid answers `204` with no session. A replacement that is not verified within 24 hours expires. **3. Update the customer** Call `PATCH /customers/{customerId}` with only `customerType` and the new address. Grid answers `202` with `payloadToSign`, `requestId`, and `expiresAt`. Make this call soon after verifying. If the verification would lapse before the challenge does, Grid returns `400 AUTH_CREDENTIAL_VERIFICATION_EXPIRED`, and the customer must verify again. ```bash theme={null} curl -X PATCH "$GRID_BASE_URL/customers/Customer:019542f5-b3e7-1d02-0000-000000000001" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerType": "INDIVIDUAL", "email": "jane.new@example.com" }' ``` **4. Signed retry** The client stamps `payloadToSign` with an active session signing key on the same account. Retry the identical request with `Grid-Wallet-Signature` and `Request-Id`. Grid answers `200` with the updated customer, and the new address becomes the account's `EMAIL_OTP` credential in place of the old one. A `200` with `{ "status": "PROCESSING" }` means the change is still landing: re-send the identical signed request until it returns the customer. A `PATCH` that changes the sign-in address without a verified replacement returns `400 AUTH_CREDENTIAL_VERIFICATION_REQUIRED`. The change must be sent on its own, so a request that also changes other customer fields returns `400 INVALID_INPUT`. Customers without an `EMAIL_OTP` or `SMS_OTP` credential of that type change `email` or `phoneNumber` with a plain update. ## Managing credentials Every Global Account starts with a single `EMAIL_OTP` credential — the one used in the [quickstart](overview#quickstart). In production, encourage customers to register a backup credential, such as a passkey or OAuth credential, so the account is recoverable if their primary device is lost. Adding, revoking, and rotating credentials after the first all go through the same **two-step signed-retry** pattern. ### List credentials ```bash theme={null} curl -X GET "$GRID_BASE_URL/auth/credentials?accountId=InternalAccount:019542f5-b3e7-1d02-0000-000000000002" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (200):** ```json theme={null} { "data": [ { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000001", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "PASSKEY", "credentialId": "KEbWNCc7NgaYnUyrNeFGX9_3Y-8oJ3KwzjnaiD1d1LVTxR7v3CaKfCz2Vy_g_MHSh7yJ8yL0Pxg6jo_o0hYiew", "nickname": "iPhone Face-ID", "createdAt": "2026-04-08T15:30:01Z", "updatedAt": "2026-04-08T15:30:01Z" }, { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000004", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-09T10:15:00Z", "updatedAt": "2026-04-09T10:15:00Z" } ] } ``` The response is not paginated — each account holds a small, bounded number of credentials. ### The signed-retry pattern Adding an additional credential, revoking a credential, refreshing or revoking a session, exporting a wallet, updating wallet privacy, and changing a sign-in email or phone number all share the same shape: ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid IB->>G: Request (no headers) G-->>IB: 202 { payloadToSign, requestId, expiresAt } IB-->>C: { payloadToSign, requestId } C->>C: stamp(payloadToSign, sessionPrivateKey) C->>IB: { stamp } IB->>G: Same request
            Grid-Wallet-Signature: stamp
            Request-Id: requestId alt still settling at the wallet provider G-->>IB: 200 { status: "PROCESSING" } IB->>G: Re-send the identical signed request end G-->>IB: 2xx (terminal success) IB-->>C: done ``` Key rules: * Always stamp the `payloadToSign` **byte-for-byte as Grid returned it**. Do not re-parse, re-serialize, or modify whitespace. * Sign with the **session private key** held on the client — never ship it back to your backend. * The retry must reach Grid before `expiresAt` (typically 5 minutes from issue). * The `requestId` is returned as `Request:` and is single-use; reusing one yields `401`. ### Handling a still-processing response The signed retry can come back `200` with a `WalletOperationProcessing` body instead of the terminal success shown above: ```json theme={null} { "status": "PROCESSING", "message": "This login is still being processed. Retry the same request in a moment." } ``` This means Grid's wallet provider accepted the operation but hasn't settled it yet — the request is not lost, and nothing failed. **Design for this as the normal path, not a rare edge case**: handle it the same way you handle the terminal success and error responses below, not as a bolted-on afterthought. Every signed-retry endpoint can return it on the retry step: | Endpoint | Action | | - | - | | `POST /auth/credentials` | Add a credential | | `POST /auth/credentials/{id}/challenge` | Re-send an OTP / re-issue a challenge | | `POST /auth/credentials/{id}/verify` | Log in / verify a credential | | `POST /auth/sessions/{id}/refresh` | Refresh a session | | `DELETE /auth/credentials/{id}` | Revoke a credential | | `DELETE /auth/sessions/{id}` | Revoke a session | | `POST /internal-accounts/{id}/export` | Export wallet credentials | | `PATCH /customers/{customerId}` | Change a sign-in email or phone number | To handle it: 1. **Re-send the exact same signed request** — identical body, `Grid-Wallet-Signature`, and `Request-Id`. Don't regenerate the stamp or start a fresh request; Grid correlates the retry with the same underlying operation and returns its real outcome once the provider settles, rather than starting a second one. 2. **Show a pending state in your UI** while you retry — "removing…" for a credential revoke, "signing in…" for a login — instead of treating `PROCESSING` as an error or leaving the caller blocked with no feedback. 3. **Keep retrying with backoff** until you get a terminal response — the success code above, or an error. Grid also reconciles the operation to its terminal state on its own, so even a client that stops retrying and checks back later (for example via `GET /auth/credentials` or `GET /auth/sessions`) will see the settled result. **Wallet export is the exception**: Grid never stores the encrypted credentials, so re-sending the signed retry is the only way to receive them. Grid's own reconciliation settles the operation but has nothing to hand back, and the export challenge expires like any other (typically 5 minutes) — if it lapses, start a fresh export. > [!NOTE] > > `verify`, `challenge`, `export`, and the customer update already use `200` for their terminal success body (`AuthSession`, the challenge response, the encrypted credentials, or the customer). For those four, `200` alone doesn't tell you which case you're in — check the response body's `status` field for `"PROCESSING"` to distinguish it from a settled success. The other four endpoints (`add credential`, `session refresh`, `revoke credential`, `revoke session`) use `201` or `204` on success, so a `200` by itself already means still-processing. ### Add an additional credential Requires an active session on an *existing* credential on the same account. The first call uses the normal credential-create body; Grid detects the pre-existing credential and responds `202` instead of `201`. `OAUTH` and `PASSKEY` are the typical additional credential types. `EMAIL_OTP` can be added back only after the existing email OTP credential has been removed, because each account supports one. **1. First call — receive the challenge** ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "OAUTH", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9..." }' ``` **Response (202):** ```json theme={null} { "type": "OAUTH", "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"oauthProviders\":[{\"oidcToken\":\"eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9...\",\"providerName\":\"Google\"}],\"userId\":\"user_2m9F...\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_CREATE_OAUTH_PROVIDERS\"}", "requestId": "Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21", "expiresAt": "2026-04-08T15:35:00Z" } ``` **2. Client stamps the payload** Send `payloadToSign` to the client. The client builds a Grid wallet signature with the session signing key from the existing credential's active session — see [signing payloads](client-keys#2-sign-a-payloadtosign). **3. Signed retry — credential is created** Re-run the same request with the stamp and request id in headers: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ -d '{ "type": "OAUTH", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "oidcToken": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyIsInR5cCI6IkpXVCJ9..." }' ``` **Response (201):** a plain `AuthMethod`. If the underlying wallet-provider activity is still settling, this returns `200 { status: "PROCESSING" }` instead — see [handling a still-processing response](#handling-a-still-processing-response); re-send the identical signed request until you get the `201`. **4. Activate the new credential** Activate the new credential the same way you would activate the first credential of that type — `OAUTH` goes straight to `POST /auth/credentials/{id}/verify` with a fresh `clientPublicKey`; `EMAIL_OTP` uses the `otpEncryptionTargetBundle` from the signed-retry registration response when present, or first calls `POST /auth/credentials/{id}/challenge` if the bundle is absent; `PASSKEY` first calls `POST /auth/credentials/{id}/challenge` with the `clientPublicKey` to get a Grid-issued WebAuthn challenge, then `POST /auth/credentials/{id}/verify` with the assertion and the `Request-Id` header. > [!NOTE] > > The account holder may have one `EMAIL_OTP` credential per internal account; an administrator's credentials, added with `POST /auth/admin-credentials`, are not counted. An administrator's session cannot add credentials with `POST /auth/credentials` (`403 FORBIDDEN`); administrators add their own with `POST /auth/admin-credentials`. Multiple distinct `PASSKEY` credentials are allowed; registering the same WebAuthn credentialId twice returns `400 PASSKEY_CREDENTIAL_ALREADY_EXISTS`. ### Revoke a credential A credential is revoked by signing with a session from **a different credential on the same account**. This prevents a compromised credential from revoking itself to lock the legitimate owner out. An account must keep at least one credential — if only one exists, the revoke call returns `400`. **1. First call — receive the challenge** ```bash theme={null} curl -X DELETE "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (202):** ```json theme={null} { "type": "PASSKEY", "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"authenticatorIds\":[\"authenticator_2m9F...\"],\"userId\":\"user_2m9F...\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_DELETE_AUTHENTICATORS\"}", "requestId": "Request:9f7a2c10-5e88-4fb1-bd0e-1c3a8e7b2d45", "expiresAt": "2026-04-08T15:35:00Z" } ``` **2. Client stamps with a different credential's session** The client stamps `payloadToSign` with the session signing key of an active session on any *other* credential (not the one being revoked). **3. Signed retry — credential is revoked** ```bash theme={null} curl -X DELETE "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:9f7a2c10-5e88-4fb1-bd0e-1c3a8e7b2d45" ``` **Response:** `204 No Content`. All active sessions issued by the revoked credential are also revoked. If the revocation is still settling at the wallet provider, this returns `200 { status: "PROCESSING" }` instead — [re-send the identical `DELETE`](#handling-a-still-processing-response) until you get the `204`, and show the credential as "removing…" in your UI in the meantime rather than assuming success or failure. # Client keys & signing Source: https://agents-docs.lightspark.com/ramps/global-accounts/client-keys Generate the P-256 client key pair, hold the session signing key, and sign account actions on Web, iOS, and Android Every signed Global Account action uses two key pairs: | Key pair | Where it lives | What it does | | - | - | - | | **Client key pair** (P-256) | On the customer's device, generated fresh per session-issuing or export request | For authentication and session refresh, its private key becomes the session signing key. For wallet export, its public key is the recipient key Grid encrypts the wallet credentials to. Ephemeral — one pair per authentication, session refresh, or wallet export. | | **Session signing key** (P-256) | Held on the customer's device | Signs every account action for the lifetime of the session (default 15 minutes). It is the private key of the client key pair sent on the request that issued the session. | This page covers generating the client key pair, sending the public key to your backend, holding the session signing key, and signing payloads. Everything here runs **on the client**; your integrator backend only relays opaque byte strings. ## 1. Generate a client key pair The key pair you generate here *is* your session key: you keep the private key on the device and use it directly to sign account actions. Grid never transmits a session key, so session-issuing responses carry no key material. Generate it as a P-256 **signing** key (ECDSA). Generate a fresh key pair for every authentication and session refresh. Where the public key goes depends on the flow: * `PASSKEY`: send it as `clientPublicKey` on `POST /auth/credentials/{id}/challenge`. * `OAUTH`: send it as `clientPublicKey` on `POST /auth/credentials/{id}/verify`. * `EMAIL_OTP`: put it inside `encryptedOtpBundle` on `POST /auth/credentials/{id}/verify`. See [Encrypt the OTP code](#encrypt-the-otp-code-email_otp-only). * Session refresh: send it as `clientPublicKey` on both `/auth/sessions/{id}/refresh` calls. Wallet export uses a different kind of key pair. See [Wallet export key pair](#wallet-export-key-pair). Keep the private key in device-local secure storage (iOS Keychain, Android Keystore, or in the browser, `IndexedDB` encrypted under a non-extractable Web Crypto key). Send the public key through your integrator backend as a **compressed** SEC1 point in **lowercase** hex: 66 characters, a `02` or `03` prefix followed by the 32-byte X coordinate. Grid rejects an uppercase or uncompressed key with `400 INVALID_INPUT`. > [!TIP] > > For local development, you can generate a P-256 key pair from the command line: > > ```bash theme={null} > # Private key (PKCS#8 PEM) > openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out private.pem > > # Compressed public key as lowercase hex > openssl ec -in private.pem -pubout -conv_form compressed -outform DER 2>/dev/null \ > | tail -c 33 | xxd -p -c 33 > ``` ```typescript Web (TypeScript) theme={null} // npm i @noble/curves import { p256 } from "@noble/curves/nist.js"; function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } // Generate a P-256 signing key pair. The 32-byte private key becomes the // session signing key, so store it encrypted at rest on the device. function generateClientKeyPair(): { privateKey: Uint8Array; publicKeyHex: string; } { const privateKey = p256.utils.randomSecretKey(); // Compressed SEC1 form: 33 bytes, 0x02 or 0x03 followed by X. const publicKeyHex = bytesToHex(p256.getPublicKey(privateKey, true)); return { privateKey, publicKeyHex }; } ``` ```kotlin Android (Kotlin) theme={null} import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import java.math.BigInteger import java.security.KeyPairGenerator import java.security.interfaces.ECPublicKey import java.security.spec.ECGenParameterSpec data class ClientKeyPair(val alias: String, val publicKeyHex: String) private fun BigInteger.toFixed32(): ByteArray { val bytes = toByteArray() return ByteArray(32).also { out -> val copyFrom = maxOf(0, bytes.size - 32) val copyLength = bytes.size - copyFrom System.arraycopy(bytes, copyFrom, out, 32 - copyLength, copyLength) } } fun generateClientKeyPair(alias: String): ClientKeyPair { val generator = KeyPairGenerator.getInstance( KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore", ) generator.initialize( KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN) .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) .setDigests(KeyProperties.DIGEST_SHA256) .build(), ) val point = (generator.generateKeyPair().public as ECPublicKey).w // Compressed SEC1 form: 0x02 for an even Y, 0x03 for an odd Y, then X. val prefix = if (point.affineY.testBit(0)) 0x03.toByte() else 0x02.toByte() val compressed = byteArrayOf(prefix) + point.affineX.toFixed32() val hex = compressed.joinToString("") { "%02x".format(it) } return ClientKeyPair(alias = alias, publicKeyHex = hex) } ``` ```swift iOS (Swift) theme={null} import CryptoKit struct ClientKeyPair { let privateKey: P256.Signing.PrivateKey let publicKeyHex: String } func generateClientKeyPair() -> ClientKeyPair { let privateKey = P256.Signing.PrivateKey() // compressedRepresentation is the 33-byte SEC1 form (0x02 or 0x03, then X). let raw = privateKey.publicKey.compressedRepresentation let hex = raw.map { String(format: "%02x", $0) }.joined() return ClientKeyPair(privateKey: privateKey, publicKeyHex: hex) } ``` > [!NOTE] > > The private key **must not leave the device**. Your integrator backend only ever sees `publicKeyHex`. ### Wallet export key pair Wallet export encrypts the wallet credentials to the key you send, so it needs a P-256 **key agreement** (ECDH) key pair rather than a signing key. Generate a fresh one for each export and discard the private key after decrypting. Send its public key as `clientPublicKey` on both `/export` calls, as an **uncompressed** SEC1 point in **lowercase** hex: 130 characters, a `04` prefix followed by the 32-byte X and 32-byte Y coordinates. The samples below produce that form. See [Exporting a wallet](exporting-wallet) for the full flow. ```typescript Web (TypeScript) theme={null} function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } // Generate a non-extractable P-256 key pair in the browser. // The private key never leaves Web Crypto; only the public key is exported. async function generateExportKeyPair(): Promise<{ keyPair: CryptoKeyPair; publicKeyHex: string; }> { const keyPair = await crypto.subtle.generateKey( { name: "ECDH", namedCurve: "P-256" }, false, // private key non-extractable ["deriveBits"], ); const raw = new Uint8Array( await crypto.subtle.exportKey("raw", keyPair.publicKey), ); // exportKey("raw") returns the 65-byte uncompressed form (0x04 || X || Y). const publicKeyHex = bytesToHex(raw); return { keyPair, publicKeyHex }; } ``` ```kotlin Android (Kotlin) theme={null} import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import java.math.BigInteger import java.security.KeyPairGenerator import java.security.interfaces.ECPublicKey import java.security.spec.ECGenParameterSpec data class ExportKeyPair(val alias: String, val publicKeyHex: String) // toFixed32() is the helper from the client key pair sample above. fun generateExportKeyPair(alias: String): ExportKeyPair { val generator = KeyPairGenerator.getInstance( KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore", ) generator.initialize( KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_AGREE_KEY) .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1")) .build(), ) val point = (generator.generateKeyPair().public as ECPublicKey).w val uncompressed = byteArrayOf(0x04) + point.affineX.toFixed32() + point.affineY.toFixed32() val hex = uncompressed.joinToString("") { "%02x".format(it) } return ExportKeyPair(alias = alias, publicKeyHex = hex) } ``` ```swift iOS (Swift) theme={null} import CryptoKit struct ExportKeyPair { let privateKey: P256.KeyAgreement.PrivateKey let publicKeyHex: String } func generateExportKeyPair() -> ExportKeyPair { let privateKey = P256.KeyAgreement.PrivateKey() // x963Representation returns uncompressed SEC1 (65 bytes starting with 0x04). let raw = privateKey.publicKey.x963Representation let hex = raw.map { String(format: "%02x", $0) }.joined() return ExportKeyPair(privateKey: privateKey, publicKeyHex: hex) } ``` ## Encrypt the OTP code (`EMAIL_OTP` only) `EMAIL_OTP` credentials never send the OTP code in plaintext. Instead, the client HPKE-encrypts the code, together with its public key, to an enclave key, so the code is unreadable in transit and Grid is only a pass-through. Grid returns an `otpEncryptionTargetBundle` whenever it initiates or reissues an OTP challenge, including `POST /auth/credentials/{id}/challenge` and add-EMAIL\_OTP signed-retry responses. First-time EMAIL\_OTP wallet bootstrap registration can omit it; if the registration response has no bundle, call `POST /auth/credentials/{id}/challenge` for that credential before verifying. The bundle is a signed enclave bundle whose `data` field is hex-encoded JSON carrying the enclave's HPKE target key as `targetPublic`. Pull out `targetPublic`, HPKE-encrypt `{ otp_code, public_key }` to it, and submit the library's `{ encappedPublic, ciphertext }` output as `encryptedOtpBundle` on `POST /auth/credentials/{id}/verify`. `public_key` must be the **compressed** public key in lowercase hex: 66 characters starting with `02` or `03`. The step 1 samples produce this form. The OTP login challenge is bound to this exact key, so the same compressed hex must be the `publicKey` in the `Grid-Wallet-Signature` stamp on the signed retry (see [step 2](#2-sign-a-payloadtosign)); a different value there fails the login. In production, an uncompressed key makes the enclave reject the OTP code, even when the code is correct. The first verify call then returns: ```json theme={null} {"code": "INVALID_INPUT", "reason": "Invalid wallet request."} ``` Grid returns this same response for other rejected requests too, including a mistyped OTP code. If you get it with a code you know is correct, check the key format first. Use an HPKE library so you don't hand-roll the suite, `info`, or AAD. The helper below assumes your crypto layer returns the JSON string Grid expects for `encryptedOtpBundle`. ```typescript Web (TypeScript) theme={null} // npm i @noble/hashes import { hexToBytes } from "@noble/hashes/utils.js"; declare function hpkeEncryptToGridBundle(args: { plainTextBuf: Uint8Array; targetKeyBuf: Uint8Array; }): string; // otpEncryptionTargetBundle: from the registration response when present, or /challenge // clientPublicKeyHex: the compressed public key from step 1 // otp: the code the user typed ("000000" in sandbox) function buildEncryptedOtpBundle( otpEncryptionTargetBundle: string, clientPublicKeyHex: string, otp: string, ): string { // Pull the enclave's target key out of the signed bundle. const { data } = JSON.parse(otpEncryptionTargetBundle) as { data: string }; const { targetPublic } = JSON.parse( new TextDecoder().decode(hexToBytes(data)), ) as { targetPublic: string }; // The enclave expects snake_case keys. const plainTextBuf = new TextEncoder().encode( JSON.stringify({ otp_code: otp, public_key: clientPublicKeyHex }), ); return hpkeEncryptToGridBundle({ plainTextBuf, targetKeyBuf: hexToBytes(targetPublic), }); } ``` > [!NOTE] > > A production client should also verify the bundle's `dataSignature` against its `enclaveQuorumPublic` before trusting `targetPublic`. The private key of the pair you generated in step 1 stays on the device. It becomes the session signing key once verification completes (see [Sign a `payloadToSign`](#2-sign-a-payloadtosign)). ## 2. Sign a `payloadToSign` Grid returns `payloadToSign` strings from several endpoints: * `POST /quotes` (when the source is a Global Account) — the quote's `paymentInstructions[].accountOrWalletInfo.payloadToSign`. * `POST /auth/credentials/{id}/verify` (`EMAIL_OTP` or `SMS_OTP`) — 202 response body; use the TEK private key generated for this login. * `POST /auth/credentials` (adding an additional credential) — 202 response body. * `DELETE /auth/credentials/{id}`, `DELETE /auth/sessions/{id}`, `POST /auth/sessions/{id}/refresh`, `POST /internal-accounts/{id}/export`, `PATCH /internal-accounts/{id}`, `PATCH /customers/{id}` for sign-in email or phone number changes — all 202 response bodies. Stamp the exact UTF-8 bytes of the `payloadToSign` string **as returned** (do not re-parse, re-serialize, or trim whitespace). For OTP verification, use the matching TEK private key; for other signed wallet actions, use an active session signing key. Build a Grid wallet signature: take its compressed P-256 public key in lowercase hex, sign the payload with the private scalar, then base64url-encode the signature envelope: ```json theme={null} { "publicKey": "", "scheme": "SIGNATURE_SCHEME_TK_API_P256", "signature": "" } ``` Pass that full stamp as the `Grid-Wallet-Signature` header on the retry (and, for endpoints that use it, echo the 202 `requestId` as `Request-Id`). > [!NOTE] > > Sign with real stamps in sandbox too. Sandbox verifies them exactly as production does. OTP verification requires a stamp over the returned `payloadToSign` with the TEK private key. ```typescript Web (TypeScript) theme={null} declare function deriveCompressedP256PublicKey(privateKeyHex: string): string; declare function signP256Sha256Der(args: { content: string; privateKeyHex: string; }): Promise; const GRID_STAMP_SCHEME = "SIGNATURE_SCHEME_TK_API_P256"; function bytesToHex(bytes: Uint8Array): string { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } function base64url(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) { binary += String.fromCharCode(byte); } return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } async function buildGridWalletSignature( sessionPrivateKeyBytes: Uint8Array, // 32 bytes, the step 1 private key payloadToSign: string, ): Promise { const apiPrivateKey = bytesToHex(sessionPrivateKeyBytes); const apiPublicKey = deriveCompressedP256PublicKey(apiPrivateKey); const signature = await signP256Sha256Der({ content: payloadToSign, privateKeyHex: apiPrivateKey, }); const stamp = JSON.stringify({ publicKey: apiPublicKey, scheme: GRID_STAMP_SCHEME, signature, }); return base64url(new TextEncoder().encode(stamp)); } ``` ```kotlin Android (Kotlin) theme={null} import android.util.Base64 import java.security.KeyStore import java.security.PrivateKey import java.security.Signature private const val GRID_STAMP_SCHEME = "SIGNATURE_SCHEME_TK_API_P256" private fun ByteArray.hex(): String = joinToString("") { "%02x".format(it) } private fun base64url(value: String): String = Base64.encodeToString( value.toByteArray(Charsets.UTF_8), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP, ) fun buildGridWalletSignature( sessionKeys: ClientKeyPair, // from generateClientKeyPair in step 1 payloadToSign: String, ): String { val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } val privateKey = keyStore.getKey(sessionKeys.alias, null) as PrivateKey val signer = Signature.getInstance("SHA256withECDSA").apply { initSign(privateKey) update(payloadToSign.toByteArray(Charsets.UTF_8)) } val derSignatureHex = signer.sign().hex() // JCE returns DER-encoded ECDSA. val stampJson = """{"publicKey":"${sessionKeys.publicKeyHex}","scheme":"$GRID_STAMP_SCHEME","signature":"$derSignatureHex"}""" return base64url(stampJson) } ``` ```swift iOS (Swift) theme={null} import CryptoKit import Foundation func buildGridWalletSignature( signingKey: P256.Signing.PrivateKey, // from generateClientKeyPair in step 1 payloadToSign: String, ) throws -> String { let payload = Data(payloadToSign.utf8) let signature = try signingKey.signature(for: payload) let signatureHex = signature.derRepresentation.map { String(format: "%02x", $0) }.joined() let publicKeyHex = signingKey.publicKey.compressedRepresentation .map { String(format: "%02x", $0) } .joined() let stampJson = #"{"publicKey":"\#(publicKeyHex)","scheme":"SIGNATURE_SCHEME_TK_API_P256","signature":"\#(signatureHex)"}"# return Data(stampJson.utf8) .base64EncodedString() .replacingOccurrences(of: "+", with: "-") .replacingOccurrences(of: "/", with: "_") .replacingOccurrences(of: "=", with: "") } ``` Your backend adds the stamp to the retry request: ```bash theme={null} curl -X POST "https://api.lightspark.com/grid/2025-10-13/quotes/Quote:019542f5-b3e7-1d02-0000-000000000006/execute" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" ``` ## Session lifetime Sessions are valid for 15 minutes by default. The `AuthSession.expiresAt` field tells you exactly when the session signing key stops being accepted. After expiry, the client must re-verify the credential (see [Authentication](authentication)) to obtain a fresh session. > [!WARNING] > > If the device is lost or compromised, the user should add a second credential from a trusted device and revoke the compromised one — see [Managing credentials](authentication#managing-credentials). To end the current browser or app session without touching credentials, see [Sessions](managing-sessions). # Exporting a wallet Source: https://agents-docs.lightspark.com/ramps/global-accounts/exporting-wallet Let a customer take the seed of their Global Account off Grid Exporting a wallet returns the wallet's mnemonic seed, encrypted to the client's public key. The customer decrypts it on their device and can then import the wallet into any compatible self-custody client. Grid never sees the plaintext seed leaving the system. Export uses the same [signed-retry pattern](authentication#the-signed-retry-pattern) as credential and session revocation — the initial `POST` returns a `payloadToSign`, and the signed retry returns the encrypted seed. Generate a fresh P-256 key agreement key pair specifically for the export (see [Wallet export key pair](client-keys#wallet-export-key-pair)). Send its public key as `clientPublicKey` on both export requests, as an uncompressed key in lowercase hex (130 characters, `04` prefix), then decrypt `encryptedWalletCredentials` with the matching private key after the signed retry succeeds. ```mermaid theme={null} sequenceDiagram participant C as Client participant IB as Integrator backend participant G as Grid IB->>G: POST /internal-accounts/{id}/export { clientPublicKey } G-->>IB: 202 { payloadToSign, requestId, expiresAt } IB-->>C: { payloadToSign, requestId } C->>C: stamp(payloadToSign, sessionPrivateKey) C->>IB: { stamp } IB->>G: POST /internal-accounts/{id}/export { same clientPublicKey }
            Grid-Wallet-Signature
            Request-Id alt still settling at the wallet provider G-->>IB: 200 { status: "PROCESSING" } IB->>G: Re-send the identical signed request end G-->>IB: 200 { id, encryptedWalletCredentials } IB-->>C: { encryptedWalletCredentials } C->>C: decrypt with client private key
            → mnemonic ``` **1. First call — receive the challenge** ```bash theme={null} curl -X POST "$GRID_BASE_URL/internal-accounts/InternalAccount:019542f5-b3e7-1d02-0000-000000000002/export" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "clientPublicKey": "04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2" }' ``` **Response (202):** ```json theme={null} { "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"targetPublicKey\":\"04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2\",\"walletId\":\"wallet_2m9F...\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_EXPORT_WALLET\"}", "requestId": "Request:c3f8a614-47e2-4a19-9f5d-2b0a91d47e08", "expiresAt": "2026-04-19T12:10:00Z" } ``` **2. Client stamps the payload** Build a Grid wallet signature over `payloadToSign` with an active session signing key on the account. Keep the export private key on the client; Grid will use the matching `clientPublicKey` from step 1 to seal the wallet credentials. **3. Signed retry — receive the encrypted seed** ```bash theme={null} curl -X POST "$GRID_BASE_URL/internal-accounts/InternalAccount:019542f5-b3e7-1d02-0000-000000000002/export" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:c3f8a614-47e2-4a19-9f5d-2b0a91d47e08" \ -d '{ "clientPublicKey": "04f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31d4245acfb88c2ebd42b47628d63ecabf345484f0a9f665b63c54c897d5578be2" }' ``` **Response (200):** ```json theme={null} { "id": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "encryptedWalletCredentials": "{\"version\":\"v1.0.0\",\"data\":\"7b22656e6361707065645075626c6963223a2230346634356632612e2e2e222c2263697068657274657874223a22316661313032333339302e2e2e222c226f7267616e697a6174696f6e4964223a226f72675f326d39462e2e2e227d\",\"dataSignature\":\"3045022100...\",\"enclaveQuorumPublic\":\"04a1b2c3...\"}" } ``` Export shares its terminal status code with the still-processing response: a `200` here is only the encrypted seed if the body isn't `{ "status": "PROCESSING" }`. See [handling a still-processing response](authentication#handling-a-still-processing-response) — if you get `PROCESSING`, re-send the identical signed retry (same `clientPublicKey`, `Grid-Wallet-Signature`, and `Request-Id`) until you get the credentials. Because Grid never stores them, the re-send is the only way to receive them; subscribe to `wallet_operation.completed` to know when it will succeed. The `Request-Id` challenge is consumed only by the attempt that actually returns credentials, so a `PROCESSING` response leaves it usable. **4. Decrypt on the client** `encryptedWalletCredentials` is a signed wallet export envelope. Parse the envelope, verify `dataSignature` against `enclaveQuorumPublic`, decode the hex `data` JSON to get `encappedPublic` and `ciphertext`, then decrypt with the export private key that matches the `clientPublicKey` you sent on both export requests. The plaintext is a BIP-39 mnemonic (the wallet's master seed). > [!WARNING] > > The exported mnemonic is the master key of the self-custody wallet. After decryption the customer is the only custodian — if the mnemonic is lost, the funds are lost. Surface appropriate warnings in your UI before running an export. # Sessions Source: https://agents-docs.lightspark.com/ramps/global-accounts/managing-sessions List and revoke active sessions on a Global Account Every call to `POST /auth/credentials/{id}/verify` creates a new **session** — an authenticated signing context with a 15-minute lifetime by default. Sessions accumulate: a customer signed in on a laptop and a phone has two active sessions, each with its own session signing key held on that device. Use the session endpoints to show active sign-ins, refresh an active session before it expires, and sign out of a specific device. ## List active sessions ```bash theme={null} curl -X GET "$GRID_BASE_URL/auth/sessions?accountId=InternalAccount:019542f5-b3e7-1d02-0000-000000000002" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (200):** ```json theme={null} { "data": [ { "id": "Session:019542f5-b3e7-1d02-0000-000000000003", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "PASSKEY", "nickname": "iPhone Face-ID", "createdAt": "2026-04-19T12:00:02Z", "updatedAt": "2026-04-19T12:00:02Z", "expiresAt": "2026-04-19T12:15:02Z" }, { "id": "Session:019542f5-b3e7-1d02-0000-000000000007", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-19T10:01:00Z", "updatedAt": "2026-04-19T10:01:00Z", "expiresAt": "2026-04-19T10:16:00Z" } ] } ``` The list endpoint returns all **active** sessions; expired sessions are not included. Session responses never carry the session signing key. The client generated it and already holds it. ## Refresh a session Session refresh issues a new session for a new session signing key, authorized by an existing active session. Generate a fresh client key pair and send its compressed public key in lowercase hex as `clientPublicKey` (see [Generate a client key pair](client-keys#1-generate-a-client-key-pair)). Use this when the customer is still present and the current session is close to expiration. If the session has already expired, reauthenticate with the original credential instead. Refresh uses the same [signed-retry pattern](authentication#the-signed-retry-pattern) as credential management, including the possibility of a [still-processing `200` response](authentication#handling-a-still-processing-response) on the signed retry — re-send the same request until you get the `201` below. **1. First call — receive the challenge** ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/sessions/Session:019542f5-b3e7-1d02-0000-000000000003/refresh" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "clientPublicKey": "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" }' ``` **Response (202):** ```json theme={null} { "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"expirationSeconds\":\"900\",\"publicKey\":\"02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_STAMP_LOGIN\"}", "requestId": "Request:8c1e7f55-7b9c-4383-86c7-0cbde77c7328", "expiresAt": "2026-04-19T12:10:00Z" } ``` **2. Client stamps the payload** Build a Grid wallet signature over `payloadToSign` with the current session signing key. **3. Signed retry — receive the refreshed session** ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/sessions/Session:019542f5-b3e7-1d02-0000-000000000003/refresh" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:8c1e7f55-7b9c-4383-86c7-0cbde77c7328" \ -d '{ "clientPublicKey": "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" }' ``` **Response (201):** the new `AuthSession`. The private key matching the `clientPublicKey` above is now the session signing key, so replace the old one on the client. ## Revoke a session Session revocation uses the same [signed-retry pattern](authentication#the-signed-retry-pattern) as credential management, including a possible [still-processing `200` response](authentication#handling-a-still-processing-response) on the signed retry. Unlike credential revocation, a session **can revoke itself** — this is how self-logout works: sign with the session key you are about to invalidate. **1. First call — receive the challenge** ```bash theme={null} curl -X DELETE "$GRID_BASE_URL/auth/sessions/Session:019542f5-b3e7-1d02-0000-000000000003" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (202):** ```json theme={null} { "type": "PASSKEY", "payloadToSign": "{\"organizationId\":\"org_2m9F...\",\"parameters\":{\"apiKeyIds\":[\"api_key_2m9F...\"],\"userId\":\"user_2m9F...\"},\"timestampMs\":\"1775681700000\",\"type\":\"ACTIVITY_TYPE_DELETE_API_KEYS\"}", "requestId": "Request:2b1e5a08-9c44-4e91-ae7f-6d0b3f8c1e22", "expiresAt": "2026-04-19T12:10:00Z" } ``` **2. Client stamps the payload** Build a Grid wallet signature over `payloadToSign` with any active session signing key on the same account — either the session being revoked (self-logout) or another session (admin-style sign-out of a different device). **3. Signed retry — session is revoked** ```bash theme={null} curl -X DELETE "$GRID_BASE_URL/auth/sessions/Session:019542f5-b3e7-1d02-0000-000000000003" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:2b1e5a08-9c44-4e91-ae7f-6d0b3f8c1e22" ``` **Response:** `204 No Content`. > [!TIP] > > Revoking a session only invalidates the session signing key, not the credential that issued it. The next call to `POST /auth/credentials/{id}/verify` on that credential still works and issues a brand new session. # Global Accounts Source: https://agents-docs.lightspark.com/ramps/global-accounts/overview Self-custody stablecoin accounts that plug into Grid payment flows A Global Account is what we consider a programmable, self-custodial account for holding and moving money. Under the hood, it is an embedded [Spark](https://spark.money) wallet that Lightspark provisions for your customer. It can hold different stablecoins, including those denominated in local currencies, as well as other digital assets such as Bitcoin. In the API, a Global Account is an internal account with `type: "EMBEDDED_WALLET"`. It uses the same customer, quote, transaction, and webhook primitives as other accounts. Receiving funds works the same way, too. To move money out, the customer must authorize the transfer on their device, which signs the payment with a session signing key. ## Why a Global Account? * **Self-custodial.** Neither Lightspark nor your platform can move funds unilaterally. Outbound transfers require authorization from the customer's device. * **Stablecoin-denominated.** Balances can be held in stablecoins like [Brale-issued USDB](https://brale.xyz/stablecoins/USDB). You can also issue your own stablecoin and [request enablement in Grid](/api-reference/stablecoins/request-grid-enablement-for-a-stablecoin). Use the `/quotes` API to convert from fiat or withdraw through supported payment rails. * **Native.** Reuse the customer, internal-account, quote, transaction, and webhook primitives you already use for payouts or P2P. Global Accounts add authentication and signing at the account level. ## Payment flow Global Accounts use the same `/quotes` and `/quotes/{id}/execute` pattern as other payments. Outbound transfers also require a client signature. * **Incoming funds.** Funding an account works like any other internal account. Create a quote with the Global Account as the `destination`, execute it, and Grid converts the source currency into USDB and credits the account. Incoming funds do not require customer approval. * **Outgoing funds.** Withdrawals and transfers out require the customer to authorize them on their device. Grid returns a `payloadToSign` in the quote's `paymentInstructions`; the client signs those bytes with its session signing key and passes the base64 signature as the `Grid-Wallet-Signature` header on `/quotes/{id}/execute`. Only then does Grid release the funds. Sessions are short-lived (15 minutes by default) and bound to a specific device via the client key pair, so a stolen signature can't be replayed from a different device or after the session expires. Standard transaction webhooks fire throughout the lifecycle. See [Transaction lifecycle](/platform-overview/core-concepts/transaction-lifecycle). ## Architecture Three parties participate in every signed action: | Party | Role | | - | - | | **Client** | The customer's device (browser, iOS app, or Android app). Generates the client key pair, runs WebAuthn, holds the session signing key, and signs outbound requests. | | **Integrator backend** | Your server. Holds your Grid API credentials, brokers every call to Grid on behalf of the client, and issues WebAuthn challenges for initial passkey registration. | | **Grid** | Verifies auth credentials, issues sessions bound to the client's public key, and enforces that every account action is authorized. | The client **never** talks to Grid directly. Every request flows client → integrator backend → Grid. ## Auth credentials, client keys, and session signing keys Three distinct pieces of crypto collaborate to authorize actions on the Global Account (withdrawals, credential changes, session revocations, wallet exports, and wallet privacy updates): | Piece | Where it lives | How long it lives | What it proves | | - | - | - | - | | **Auth credential**: passkey, OIDC token, or email OTP | Registered on the account; the passkey itself lives on the authenticator, OIDC on your IdP, OTP in the user's inbox | Until the customer revokes it | *"I am the human who owns this account."* Used to authenticate the user at the start of each session. | | **Client key pair** (P-256) | Generated on the client device for each session-issuing or export request; private key stays in device-local secure storage | One authentication, session refresh, or wallet export | Binds a session or wallet export to the exact device that asked for it. For a session, its private key is the session signing key. For a wallet export, Grid encrypts the credentials to its public key, so only this device can decrypt them. | | **Session signing key** (P-256) | The private key of the client key pair that issued the session; held on the device for the session's lifetime | 15 minutes (default) | *"This specific account action was approved on an authenticated device."* Builds Grid wallet signatures over the `payloadToSign` Grid returns on quotes, credential changes, session refresh/revocation, wallet exports, sign-in email or phone number changes, and wallet privacy updates. | The flow is always the same: verify an auth credential with a fresh client key pair → hold its private key as the short-lived session signing key → build a Grid wallet signature over the `payloadToSign` bytes on the client → pass that signature as the `Grid-Wallet-Signature` header on the request that actually moves funds or changes account state. This applies to withdrawals, adding or removing credentials, refreshing or revoking sessions, exporting the wallet seed, changing the sign-in email or phone number, and updating wallet privacy. ## Prerequisites > [!WARNING] > > Customers who hold a Global Account must be KYC/KYB verified before any account funds can move from or to fiat rails. This quickstart picks up after KYC is complete. > > In sandbox, customers are automatically KYC approved on creation so you can skip straight to account setup. You also need: * A platform configured with `USDB` in its supported currencies. In sandbox, USDB is enabled by default alongside `USD` and `USDC`. * Sandbox or production API credentials with access to the `Embedded Wallet Auth` and `Internal Accounts` endpoints. ```bash theme={null} export GRID_BASE_URL="https://api.lightspark.com/grid/2025-10-13" export GRID_CLIENT_ID="YOUR_SANDBOX_CLIENT_ID" export GRID_CLIENT_SECRET="YOUR_SANDBOX_CLIENT_SECRET" ``` ## Walkthrough The walkthrough below is the happy path: create a customer, find the auto-provisioned account and its default email OTP credential, fund it, and withdraw to a bank account. Each step shows the HTTP request your integrator backend makes on behalf of the client. ### 1. Create a customer Create the customer record. A Global Account is provisioned automatically whenever a customer is created on a platform that has `USDB` in its supported currencies — you don't need to pass it on the customer. ```bash theme={null} curl -X POST "$GRID_BASE_URL/customers" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerType": "INDIVIDUAL", "platformCustomerId": "ind-9f84e0c2", "region": "US", "email": "jane@example.com", "fullName": "Jane Doe", "birthDate": "1990-01-15", "nationality": "US" }' ``` **Response:** `201 Created` with the new `Customer:...` id. In sandbox, the customer is KYC-approved immediately; in production you would now run them through the KYC / KYB flow before any funds can move. ### 2. Find the Global Account When a customer is created on a USDB-enabled platform, Grid automatically provisions a Global Account alongside their other internal accounts. Fetch it by filtering the customer's internal accounts by `type=EMBEDDED_WALLET`. ```bash theme={null} curl -X GET "$GRID_BASE_URL/internal-accounts?customerId=Customer:019542f5-b3e7-1d02-0000-000000000001&type=EMBEDDED_WALLET" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response:** ```json theme={null} { "data": [ { "id": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "type": "EMBEDDED_WALLET", "balance": { "amount": 0, "currency": { "code": "USDB", "name": "USDB", "decimals": 6 } }, "totalBalance": { "amount": 0, "currency": { "code": "USDB", "name": "USDB", "decimals": 6 } }, "fundingPaymentInstructions": [], "createdAt": "2026-04-19T12:00:00Z", "updatedAt": "2026-04-19T12:00:00Z" } ], "hasMore": false, "totalCount": 1 } ``` Hold onto the `InternalAccount:...` id — every auth credential is scoped to it. ### 3. Find the default email OTP credential Global Accounts are initialized with an `EMAIL_OTP` credential tied to the customer email on file. Fetch the auth methods for the account and keep the `AuthMethod:...` id for the signing step later in this walkthrough. ```bash theme={null} curl -X GET "$GRID_BASE_URL/auth/credentials?accountId=InternalAccount:019542f5-b3e7-1d02-0000-000000000002" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response:** ```json theme={null} { "data": [ { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000001", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-19T12:00:01Z", "updatedAt": "2026-04-19T12:00:01Z" } ] } ``` You can add passkeys or OAuth credentials later, but adding credentials is itself a signed action. Start with the default email OTP credential to mint the first session signing key. ### 4. Fund the Account Global Accounts behave like any other internal account on the way in — incoming funds do not need the customer's signature. In sandbox, use the sandbox funding endpoint to skip straight to a funded state: ```bash theme={null} curl -X POST "$GRID_BASE_URL/sandbox/internal-accounts/InternalAccount:019542f5-b3e7-1d02-0000-000000000002/fund" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "amount": 1000000000 }' ``` `amount` is in the smallest unit of the account's currency. USDB has 6 decimals, so `1000000000` is 1,000.00 USDB. You will receive an `INCOMING_PAYMENT` webhook when the balance updates. The account now holds 1,000.00 USDB. > [!TIP] > > To fund from another currency (USD ACH, USDC on-chain, etc.), create a quote with `destination.destinationType: "ACCOUNT"` pointing at the Global Account's `InternalAccount` id. The quote's `sourceCurrency` can be any supported platform currency; Grid will convert into USDB on execute. ### 5. Add an external bank account Add the destination the customer wants to withdraw to. This is a standard external account — nothing Global Account-specific. ```bash theme={null} curl -X POST "$GRID_BASE_URL/customers/external-accounts" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "currency": "USD", "platformAccountId": "jane_doe_checking", "accountInfo": { "accountType": "USD_ACCOUNT", "accountNumber": "1234567890", "routingNumber": "021000021", "bankAccountType": "CHECKING", "beneficiary": { "beneficiaryType": "INDIVIDUAL", "fullName": "Jane Doe", "birthDate": "1990-01-15", "nationality": "US", "address": { "line1": "123 Main Street", "city": "San Francisco", "state": "CA", "postalCode": "94105", "country": "US" } } } }' ``` **Response:** `201 Created` with the new `ExternalAccount:...` id. ### 6. Create a withdrawal quote Create a quote with the Global Account as the source. Grid returns a `payloadToSign` in the quote's payment instructions — this is what the client will sign to authorize the transfer. ```bash theme={null} curl -X POST "$GRID_BASE_URL/quotes" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 10000000, "description": "Withdrawal to checking" }' ``` `lockedCurrencyAmount` is in the smallest unit of the locked side's currency. Here the sending currency is USDB (6 decimals), so `10000000` is 10.00 USDB. **Response:** ```json theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000006", "status": "PENDING", "createdAt": "2026-04-19T12:05:00Z", "expiresAt": "2026-04-19T12:10:00Z", "source": { "sourceType": "ACCOUNT", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:a12dcbd6-dced-4ec4-b756-3c3a9ea3d123" }, "sendingCurrency": { "code": "USDB", "name": "USDB", "decimals": 6 }, "receivingCurrency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 }, "totalSendingAmount": 10000000, "totalReceivingAmount": 975, "exchangeRate": 1.0, "feesIncluded": 250000, "platformFeesIncluded": 0, "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000005", "paymentInstructions": [ { "accountOrWalletInfo": { "accountType": "EMBEDDED_WALLET", "payloadToSign": "{\"type\":\"ACTIVITY_TYPE_SIGN_TRANSACTION_V2\",\"timestampMs\":\"1746736509954\",\"organizationId\":\"org_abc123\",\"parameters\":{\"signWith\":\"wallet_abc123def456\",\"unsignedTransaction\":\"ea69b4bf05f775209f26ff0a34a05569180f7936579d5c4af9377ae550194f72\",\"type\":\"TRANSACTION_TYPE_ETHEREUM\"},\"generateAppProofs\":true}" }, "instructionsNotes": "Stamp the payloadToSign byte-for-byte and pass the stamp as the Grid-Wallet-Signature header on execute" } ] } ``` ### 7. Authenticate and sign The customer has an outstanding quote with a `payloadToSign`. Now we need a session signing key to sign it with. With `EMAIL_OTP`, the client generates a TEK (Target Encryption Key) pair, HPKE-encrypts the OTP code, and uses the TEK private key both to complete login and to sign the quote payload. **1. Your backend requests a fresh OTP** Ask Grid to send a fresh OTP email for the default `EMAIL_OTP` credential. The response includes `otpEncryptionTargetBundle` for the secure OTP flow. ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/challenge" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response (200):** ```json theme={null} { "id": "AuthMethod:019542f5-b3e7-1d02-0000-000000000001", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "otpEncryptionTargetBundle": "{\"version\":\"v1.0.0\",\"data\":\"7b22...\",\"dataSignature\":\"3045...\",\"enclaveQuorumPublic\":\"04a1...\"}", "createdAt": "2026-04-19T12:00:01Z", "updatedAt": "2026-04-19T12:05:00Z" } ``` Return `otpEncryptionTargetBundle` to the client. **2. Client encrypts the OTP and verifies** The client generates a fresh [P-256 key pair (the TEK)](client-keys#1-generate-a-client-key-pair), [HPKE-encrypts](client-keys#encrypt-the-otp-code-email_otp-only) `{otp_code, public_key}` under `otpEncryptionTargetBundle`, and sends the encrypted bundle to your backend. `public_key` must be the compressed public key (`02` or `03` prefix). In sandbox, use OTP code `000000`. Your backend calls verify with the encrypted bundle: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "type": "EMAIL_OTP", "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" }' ``` **Response (202):** ```json theme={null} { "type": "EMAIL_OTP", "payloadToSign": "{\"login\":{\"publicKey\":\"02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31\"},\"tokenId\":\"7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21\",\"type\":\"USAGE_TYPE_LOGIN\"}", "requestId": "Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21", "expiresAt": "2026-04-19T12:10:00Z" } ``` Return `payloadToSign` and `requestId` to the client. **3. Client stamps payloadToSign and completes login** The client [stamps](client-keys#2-sign-a-payloadtosign) the exact UTF-8 bytes of `payloadToSign`, unchanged, with the TEK private key and sends the stamp back to your backend. Your backend retries the same request with the stamp: ```bash theme={null} curl -X POST "$GRID_BASE_URL/auth/credentials/AuthMethod:019542f5-b3e7-1d02-0000-000000000001/verify" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -H "Grid-Wallet-Signature: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" \ -H "Request-Id: Request:7c4a8d09-ca37-4e3e-9e0d-8c2b3e9a1f21" \ -d '{ "type": "EMAIL_OTP", "encryptedOtpBundle": "{\"encappedPublic\":\"044f631a...\",\"ciphertext\":\"1fa1023390...\"}" }' ``` **Response (200):** ```json theme={null} { "id": "Session:019542f5-b3e7-1d02-0000-000000000003", "accountId": "InternalAccount:019542f5-b3e7-1d02-0000-000000000002", "type": "EMAIL_OTP", "nickname": "jane@example.com", "createdAt": "2026-04-19T12:05:01Z", "updatedAt": "2026-04-19T12:05:01Z", "expiresAt": "2026-04-19T12:20:01Z" } ``` The TEK public key is now the session API key. The TEK private key **is** the session signing key — the client already has it. **4. Client stamps the quote payload** The client [signs the quote's `payloadToSign`](client-keys#2-sign-a-payloadtosign) with the same TEK private key. Return the full Grid wallet signature to your backend. > [!WARNING] > > Stamp the `payloadToSign` bytes exactly as Grid returned them. Do not parse, re-serialize, trim, or normalize the JSON — the stamp must cover the same bytes Grid's verifier hashes. The session signing key is now valid for 15 minutes, so subsequent account actions within that window (for example, a second withdrawal) can reuse it without another `/challenge` + `/verify` round-trip. ### 8. Execute the quote Call `/execute` with the stamp in the `Grid-Wallet-Signature` header. ```bash theme={null} curl -X POST "$GRID_BASE_URL/quotes/Quote:019542f5-b3e7-1d02-0000-000000000006/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: eyJwdWJsaWNLZXkiOiIwMmExYjIuLi4iLCJzY2hlbWUiOiJTSUdOQVRVUkVfU0NIRU1FX1RLX0FQSV9QMjU2Iiwic2lnbmF0dXJlIjoiMzA0NTAyMjEwMC4uLiJ9" ``` **Response:** ```json theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000006", "status": "PROCESSING", "transactionId": "Transaction:019542f5-b3e7-1d02-0000-000000000005", "totalSendingAmount": 10000000, "totalReceivingAmount": 975, "feesIncluded": 250000 } ``` The transaction is on its way. You'll receive standard transaction webhooks (`OUTGOING_PAYMENT`) as it settles — see [Transaction lifecycle](/platform-overview/core-concepts/transaction-lifecycle). ## Where to next > [card] [Client keys & signing](./client-keys) > > Generate the P-256 key pair, hold the session signing key, and sign payloads on Web, iOS, and Android. > [card] [Authentication](./authentication) > > OAuth and Email OTP flows, passkey reauthentication, and the full WebAuthn parameter mapping. > [card] [Sessions](./managing-sessions) > > List, refresh, and revoke active sessions. > [card] [Exporting a wallet](./exporting-wallet) > > Let a customer take their wallet seed off Grid. # Ramps Source: https://agents-docs.lightspark.com/ramps/index ![Ramps hero](https://mintcdn.com/agents-docs/qL8kqlSYbvexR8cg/images/heroes/hero-ramps.jpg?fit=max\&auto=format\&n=qL8kqlSYbvexR8cg\&q=85\&s=34dd43731898a1813a2c2fe317bdd9e8) With Grid, you can seamlessly convert between fiat currencies and cryptocurrencies through a single, simple API. The Grid automatically handles currency conversion, compliance, and instant settlement via the Lightning Network. ![](/images/icons/lightning.svg) Instant Conversion Convert fiat to crypto (on-ramp) or crypto to fiat (off-ramp) in seconds using real-time exchange rates. ![](/images/icons/shield.svg) Simplified Infrastructure Grid handles the conversion and settlement process, reducing complexity in your crypto integration. ![](/images/icons/globe.svg) Global Settlement Leverages the Lightning Network for instant Bitcoin transfers and local banking rails for fiat settlement worldwide. *** ## How Ramps Work **1. Quote Creation** Get real-time exchange rates and payment instructions for your desired conversion (fiat → crypto or crypto → fiat). **2. Funding & Conversion** Fund the quote using the provided payment instructions. Grid executes the conversion at the quoted rate. **3. Settlement** Receive your converted funds via Lightning Network (for crypto) or local banking rails (for fiat) within seconds. *** ## Grid Features Users interact with Grid through two main interfaces: > [tab] API > > Programmatic access to create quotes, fund conversions, execute transfers, > and reconcile all activity with real-time webhooks. > [tab] Dashboard > > Your development and operations team can use the dashboard to monitor > conversions, manage API keys and environments, and troubleshoot with > detailed logs. ### On-Ramp: Fiat to Crypto Convert fiat currency to cryptocurrency and deliver it to self-custody wallets. * **Create quotes** to lock exchange rates for fiat-to-crypto conversions * **Payment instructions** guide users on how to fund their conversion * **Instant delivery** to Spark wallets or other supported crypto destinations * **Webhook notifications** confirm successful conversion and delivery ### Off-Ramp: Crypto to Fiat Convert cryptocurrency to fiat currency and deliver it to bank accounts. * **Pre-funded accounts** hold crypto balances for conversion * **Real-time rates** for crypto-to-fiat exchange * **Bank account delivery** via local payment rails * **Compliance** handled automatically through Grid's infrastructure ### Funding Options Grid supports multiple funding models for on-ramps and off-ramps: * **Just-in-time (JIT)**: Create a quote and fund it in real-time using payment instructions (ideal for on-ramps) * **Pre-funded accounts**: Maintain balances in crypto or fiat and convert from those balances (ideal for off-ramps) > [!TIP] > > You can mix funding models based on your use case. On-ramps typically use JIT > funding, while off-ramps use pre-funded accounts. ### Environments > [!NOTE] > > Grid supports two environments: **Sandbox** and **Production**. > > The Sandbox mirrors production behavior, allowing you to test the full end-to-end flow—from creating quotes and simulating funding to executing conversions and receiving webhooks—without moving real funds. > > The Production environment uses live credentials and base URLs for real transactions once you're ready to launch. *** ![](/images/icons/light-bulb.svg) Get Started Ready to integrate Grid? Check out our quickstart guide. [Open](/ramps/quickstart) # Configuring Customers Source: https://agents-docs.lightspark.com/ramps/onboarding/configuring-customers Create and manage customers for ramp conversions Customers must complete identity verification before processing conversions. The required information varies based on your platform's regulatory status. ## What to collect What you collect depends on the customer type: **KYC** for `INDIVIDUAL` customers, **KYB** for `BUSINESS` customers. Grid validates against these requirements when you call `POST /verifications` — anything missing comes back as a `RESOLVE_ERRORS` entry naming the exact field or document. > [!NOTE] > > Most of these fields are optional to the API schema at customer creation and only become mandatory at verification. Two things widen the list further: the `providerRequiredCustomerFields` for each currency in your platform's configuration (`GET /config`), and the KYC provider's per-jurisdiction rules. Treat the lists below as the baseline, not the ceiling. ### KYC — individual customers Personal information, submitted via `POST /customers` (or `PATCH /customers/{customerId}`): | Field | Notes | | - | - | | `fullName` | Must contain both a given name and a family name — a single-word name is rejected at submission | | `birthDate` | `YYYY-MM-DD` | | `nationality` | ISO 3166-1 alpha-2 | | `address` | `line1`, `postalCode`, and `country` are required; `line2`, `city`, and `state` as applicable | | `idType` + `identifier` | A tax ID: `SSN` (format `###-##-####`) or `ITIN`, or `NON_US_TAX_ID` with its non-US `countryOfIssuance`. US residents must provide an `SSN` or `ITIN` | | `email` / `phoneNumber` | Required in regions that verify a contact channel before verification begins (for example, the EU) | Documents, uploaded via `POST /documents` — identity documents also require `documentNumber` and `issuingAuthority`: * **Identity document** — a `PASSPORT` (single image), or a `DRIVERS_LICENSE` / `NATIONAL_ID` / `RESIDENCE_PERMIT` uploaded as separate `FRONT` and `BACK` images via the `side` field. * **Proof of address** — requested in some jurisdictions and for some currencies. Any of `UTILITY_BILL`, `ELECTRICITY_BILL`, `RENT_OR_LEASE_AGREEMENT`, `BANK_STATEMENT`, or `TAX_RETURN`, dated within the last 3 months. Some jurisdictions, flows, and volume tiers also require enhanced due diligence. These are optional top-level fields on the individual customer — `sourceOfFundsCategories`, `sourceOfWealthCategories`, `purposeOfAccount`, `expectedMonthlyTransactionCount`, `expectedMonthlyTransactionVolume`, `annualIncomeRange`, `netWorthRange`, and `pepStatus`. The provider decides which apply; you learn which ones through `MISSING_FIELD` errors on `POST /verifications` rather than up front. Each `*Categories` field that includes `OTHER` also requires its matching `*OtherDescription`. ### KYB — business customers When Grid runs KYB for a business customer, the following information and documents are collected before onboarding completes. Use this list to plan what to gather from the business — provide it via `POST /customers`, `POST /beneficial-owners`, and `POST /documents` (or through the hosted flow). #### Business identifying information * Entity full legal name * Doing Business As (DBA) name, if applicable * Physical address — the principal place of business, local office, or other physical location of the entity opening the account * Countries of operation * Identification number — U.S. taxpayer identification number, or, for a foreign business without one, alternative government-issued documentation certifying the existence of the business #### Ownership and control structure Collected for: * **One control person** — a single individual with significant responsibility to control, manage, or direct the legal entity, **and** * **All beneficial owners** — every individual who owns 25% or more of the legal entity, directly or indirectly. For every such individual, provide: * Full name * Date of birth * Address * Identification number, by residency: * **U.S. persons** — Social Security Number (SSN) or Individual Taxpayer Identification Number (ITIN) * **Non-U.S. persons** — one or more of: ITIN, passport number with country of issuance, alien identification card number, or another government-issued document evidencing nationality or residence and bearing a photograph or similar safeguard #### Required documents * Company formation and existence documents. For example: * Certificate of incorporation * Articles of association * Proof of ownership and control structure. For example: * Corporate organization and ownership chart * Shareholder agreements * Operating agreements * Register of members * Certification of controlling person and beneficial owners * Proof of address, dated within the last 3 months. For example: * Utility bill * Bank statement * Lease agreement * Official correspondence * Tax ID or equivalent identifying-number documents * For non-U.S. beneficial owners — passport plus one additional government-issued ID. For example: * National ID ## Regulated platforms > [!TIP] > > **Regulated platforms** have lighter KYC requirements since they handle compliance verification internally. The KYC/KYB flow allows you to onboard customers through direct API calls. Regulated financial institutions can: * **Direct API Onboarding**: Create customers directly via API calls with minimal verification * **Internal KYC/KYB**: Handle identity verification through your own compliance systems * **Reduced Documentation**: Only provide essential customer information required by your payment counterparty or service provider. * **Faster Onboarding**: Streamlined process for known, verified customers ### Creating customers via direct API For regulated platforms, you can create customers directly through the API without requiring external KYC verification: To register a new customer in the system, use the `POST /customers` endpoint: ```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": "customer_12345", "customerType": "INDIVIDUAL", "fullName": "Jane Doe", "birthDate": "1992-03-25", "nationality": "US", "address": { "line1": "123 Pine Street", "city": "Seattle", "state": "WA", "postalCode": "98101", "country": "US" } }' ``` The examples below show a more comprehensive set of data. Not all fields are strictly required by the API for customer creation itself, but become necessary based on currency and UMA provider requirements if using UMA. > [tab] Individual customer > > ```json theme={null} > { > "platformCustomerId": "9f84e0c2a72c4fa", > "customerType": "INDIVIDUAL", > "fullName": "John Sender", > "birthDate": "1985-06-15", > "address": { > "line1": "Paseo de la Reforma 222", > "line2": "Piso 15", > "city": "Ciudad de México", > "state": "Ciudad de México", > "postalCode": "06600", > "country": "MX" > } > } > ``` > [tab] Business Customer > > ```json theme={null} > { > "platformCustomerId": "b87d2e4a9c13f5b", > "customerType": "BUSINESS", > "businessInfo": { > "legalName": "Acme Corporation", > "country": "US", > "registrationNumber": "789012345", > "taxId": "123-45-6789", > "incorporatedOn": "2018-03-14", > "businessType": "INFORMATION" > }, > "address": { > "line1": "456 Oak Avenue", > "line2": "Floor 12", > "city": "Seattle", > "state": "WA", > "postalCode": "98101", > "country": "US" > } > } > ``` ## Unregulated platforms > [!NOTE] > > **Unregulated platforms** rely on Grid to run KYC for individuals and KYB for businesses. You can onboard customers either through the **hosted KYC/KYB link flow** below, or by **submitting customer data directly through the API**. Both paths cover KYC and KYB, produce the same status transitions — on `kycStatus` for individuals, `kybStatus` for businesses — and emit the same `CUSTOMER.KYC_*` / `CUSTOMER.KYB_*` webhooks. You can also mix them: supply what you already hold through the API, then let the hosted flow collect the rest. Either path works for unregulated platforms: * **Hosted flow**: Redirect customers to a Grid-hosted link (or embed the provider SDK). Covers individuals and businesses alike. Best when you want Grid to handle the entire collection UX. * **Direct API onboarding**: Collect customer information in your own UI and submit it via the API. For `INDIVIDUAL` customers (KYC), personal information goes through `POST /customers`. For `BUSINESS` customers (KYB), you also register beneficial owners via `POST /beneficial-owners`. Submit for review with `POST /verifications`. ### Hosted KYC and KYB Link Flow The hosted flow provides a secure, hosted interface where customers complete verification. It serves both customer types — there is no separate KYB link endpoint. `POST /customers/{customerId}/kyc-link` generates the link, and `customerType` selects which flow the provider runs: * **`INDIVIDUAL`** — identity verification (KYC), tracked on `kycStatus`. * **`BUSINESS`** — business verification (KYB), tracked on `kybStatus`. The applicant confirms the company details, uploads the formation, ownership, and proof-of-address documents, and declares the control person and every beneficial owner holding 25% or more. The flow is two steps: create the customer with the information you have, then generate the link. The status starts at `UNVERIFIED` and moves to `PENDING` once the customer completes the flow and review begins. #### 1. Create the customer > [tab] Individual (KYC) > > Supply at least `customerType` and any fields you already have. > > ```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 '{ > "customerType": "INDIVIDUAL", > "platformCustomerId": "9f84e0c2a72c4fa", > "region": "US", > "currencies": ["USD", "USDC"], > "email": "jane.doe@example.com", > "fullName": "Jane Doe" > }' > ``` > [tab] Business (KYB) > > `customerType: BUSINESS` requires these fields — the request fails without them: > > | Field | Notes | > | - | - | > | `businessInfo.legalName` | Full legal entity name | > | `businessInfo.country` | Country of incorporation, ISO 3166-1 alpha-2. Sets the applicant's jurisdiction and the tax-ID format validated against | > | `businessInfo.taxId` | Validated against `businessInfo.country` | > | `businessInfo.incorporatedOn` | `YYYY-MM-DD` | > > ```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 '{ > "customerType": "BUSINESS", > "platformCustomerId": "biz-acme-001", > "region": "US", > "currencies": ["USD", "USDC"], > "email": "finance@acme.com", > "businessInfo": { > "legalName": "Acme Corporation, Inc.", > "country": "US", > "taxId": "47-1234567", > "incorporatedOn": "2018-03-14", > "registrationNumber": "5523041", > "entityType": "LLC" > } > }' > ``` > > Everything else is optional to the schema, but three groups matter in practice: > > * **Contact channels** — `email` and/or `phoneNumber`, plus `businessInfo.primaryContactFirstName` and `primaryContactLastName`, are required in regions that verify a named representative before verification begins (for example the EU). > * **Currency-driven fields** — anything listed in `providerRequiredCustomerFields` for a currency the business will transact in. > * **Everything the review needs** — `address`, `registrationNumber`, `entityType`, `countriesOfOperation`, `businessType`, `purposeOfAccount`, `sourceOfFunds`, and the expected-activity fields aren't enforced at creation. Whatever you omit, the applicant is asked for in the flow. Persist the returned `id` (the Grid customer ID) — you'll need it for the next step. > [!TIP] > > Business information you supply is prefilled into the hosted flow, so send as much as you have — via `POST /customers` or a later `PATCH /customers/{customerId}` — **before** generating the link. Every field you pass is one the applicant doesn't retype, and one fewer chance for their answer to diverge from your record. #### 2. Generate the link ```bash theme={null} curl -X POST "https://api.lightspark.com/grid/2025-10-13/customers/Customer:019542f5-b3e7-1d02-0000-000000000001/kyc-link" \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ -H "Content-Type: application/json" \ -d '{ "redirectUri": "https://yourapp.com/onboarding-complete" }' ``` **Response:** ```json theme={null} { "kycUrl": "https://kyc.lightspark.com/onboard/abc123def456", "expiresAt": "2027-01-15T14:32:00Z", "provider": "SUMSUB", "token": "_act-sbx-jwt-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." } ``` > [!TIP] > > The response field is named `kycUrl` for both customer types; for a `BUSINESS` customer it opens the KYB flow. For providers that support direct SDK integration (currently SUMSUB), a `token` is also returned — pass it to the provider's web SDK to embed verification in your own UI instead of redirecting. Both update the customer's status identically. #### The flow end to end **1. Create the customer** Call `POST /customers` with `customerType` and any pre-fill fields you have. The returned `id` is the customer's Grid ID. Their status — `kycStatus` for an individual, `kybStatus` for a business — starts at `UNVERIFIED`, becomes `PENDING` while under review, and lands on `APPROVED` or `REJECTED` when the decision is made. (`HOLD` means the customer may be asked to supply more information.) **2. Verify contact channels (only where required)** Some regions (for example, the EU) require the customer's email and/or phone to be verified before verification can begin. For a business customer, these are the primary contact's details. When this applies, the customer carries a `contactVerification` object listing **only the channels that are required** — for example `{ "email": "PENDING", "phone": "PENDING" }`, or just `{ "email": "PENDING" }` if a provider requires email alone. When the object is absent, no contact verification is required and you can skip this step. For each channel that's present, drive a send-then-confirm pair (the provider delivers a one-time code by email/SMS): * `POST /customers/{customerId}/verify-email`, then `POST /customers/{customerId}/verify-email/confirm` with `{ "code": "..." }` * `POST /customers/{customerId}/verify-phone`, then `POST /customers/{customerId}/verify-phone/confirm` with `{ "code": "..." }` Re-POST the send endpoint to resend a code. Each channel moves `PENDING` → `VERIFIED`. (`verify-email`/`verify-phone` return `409` for a channel the provider doesn't require.) > [!NOTE] > > Generating the link before every present channel is `VERIFIED` returns `409`. **3. Generate the link** Call `POST /customers/{customerId}/kyc-link`. Each call returns a fresh single-use `kycUrl` and `expiresAt`; previously-issued links remain single-use but aren't invalidated. > [!NOTE] > > The `redirectUri` you pass is embedded in the generated `kycUrl` and is used to automatically return the customer to your application after they complete verification. **4. Send the customer through verification** Redirect the customer to `kycUrl`, or — if you want to embed the flow directly — initialize the provider's SDK with the returned `token`. A business applicant completes the company details, uploads the company documents, and declares the beneficial owners here. > [!WARNING] > > The hosted URL is single-use and expires at `expiresAt`. If a customer needs to retry, call the endpoint again to generate a new link. **5. Track the decision** Reaching your `redirectUri` only means the customer **finished the hosted flow** — not that they were approved. Wait for the final decision in one of two ways: * **Webhook (recommended):** Subscribe to `CUSTOMER.KYC_APPROVED` / `CUSTOMER.KYC_REJECTED` (and `CUSTOMER.KYB_APPROVED` / `CUSTOMER.KYB_REJECTED` for business customers) to be notified when the customer reaches a terminal status. `CUSTOMER.KYC_PENDING` (and the `KYB_PENDING` sibling) also fires when the customer is submitted for review — subscribe to it as well if you want to surface an "under review" state to the customer. * **Polling:** Call `GET /customers/{customerId}` and inspect `kycStatus` (individuals) or `kybStatus` (businesses). `GET /verifications?customerId=...` gives the finer-grained `verificationStatus` and `errors` array. That detail drives direct API onboarding; in the hosted flow the customer resolves it inside the flow, so integrate against `kycStatus` / `kybStatus`. **6. Handle completion** On `APPROVED`, the customer is ready to transact — proceed with account setup and unlock funding. On `REJECTED`, surface the appropriate next step (for example, regenerate the link or request manual review). While the status is `PENDING`, let the customer finish account setup but block funding and money movement. > [!WARNING] > > Testing the business flow in sandbox: by default the hosted flow collects the full packet, and a `businessInfo.registrationNumber` **not** ending in `001`, `002`, or `003` auto-approves the customer once that submission is complete. With **Skip verification paperwork** on, that suffix resolves before any packet validation, leaving the hosted flow nothing to verify — so it asks for no documents and no beneficial owners. Use a `003` suffix to exercise document and UBO collection. See [Sandbox testing](/api-reference/sandbox-testing). ### Direct API Onboarding Prefer to collect identity information in your own UI and submit it to Grid yourself? Use the API directly instead of redirecting to a hosted link. The customer's `kycStatus` transitions the same way and you receive the same `CUSTOMER.KYC_APPROVED` / `CUSTOMER.KYC_REJECTED` / `CUSTOMER.KYC_PENDING` (and `CUSTOMER.KYB_*` equivalents) webhooks. The shape of the flow depends on the customer type: * **KYC (`INDIVIDUAL` customers)** — supply the customer's personal information through the customer endpoint. No beneficial owners are involved. * **KYB (`BUSINESS` customers)** — create the business customer, then register its beneficial owners, directors, and officers individually. > [tab] KYC (individual) > > **1. Create the customer with personal information** > > Call `POST /customers` with `customerType: INDIVIDUAL` and the personal information collected from the customer (legal name, date of birth, address, nationality, etc.). The returned `id` is the customer's Grid ID; `kycStatus` starts at `UNVERIFIED` and moves to `PENDING` once you submit for verification. > > > [!NOTE] > > > > `fullName` must contain both a given name and a family name (for example, `"Jane Doe"`). The name is split into first/last for the verification provider, and a single-word name is rejected at submission. > > ```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 '{ > "customerType": "INDIVIDUAL", > "platformCustomerId": "9f84e0c2a72c4fa", > "fullName": "Jane Doe", > "birthDate": "1985-06-15", > "nationality": "US", > "email": "jane.doe@example.com", > "address": { > "line1": "123 Pine Street", > "city": "Seattle", > "state": "WA", > "postalCode": "98101", > "country": "US" > } > }' > ``` > > > [!NOTE] > > > > Where the customer's provider requires it (for example, EU customers), the customer carries a `contactVerification` object and their email and phone must be `VERIFIED` before you submit for verification. Drive each channel with the send-then-confirm pairs (`verify-email` / `verify-email/confirm` and `verify-phone` / `verify-phone/confirm`) described in the Hosted flow's "Verify email and phone" step. When `contactVerification` is absent, no contact verification is required. > > **2. Upload the identity document** > > Individual verification requires an identity document — a `PASSPORT` (single image), or a `DRIVERS_LICENSE` / `NATIONAL_ID` / `RESIDENCE_PERMIT` (uploaded as separate `FRONT` and `BACK` images via the `side` field). Some jurisdictions or currencies also request additional documents such as proof of address. Upload each one with `POST /documents` using `multipart/form-data`; identity documents also require `documentNumber` and `issuingAuthority`. Files must be `application/pdf`, `image/jpeg`, or `image/png`, and 10 MB or smaller. A photo of a document has to show all four corners in frame, in focus and in color, or the upload is rejected with `422 DOCUMENT_REJECTED`. > > ```bash theme={null} > curl -X POST "https://api.lightspark.com/grid/2025-10-13/documents" \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -F "documentHolder=Customer:019542f5-b3e7-1d02-0000-000000000001" \ > -F "documentType=PASSPORT" \ > -F "documentNumber=A12345678" \ > -F "issuingAuthority=U.S. Department of State" \ > -F "country=US" \ > -F "file=@./passport.jpg" > ``` > > **3. Submit for verification** > > Call `POST /verifications` to submit the customer for review. The response includes a `verificationStatus`. If anything is missing, `verificationStatus` is `RESOLVE_ERRORS` and the `errors` array describes exactly what to collect before retrying. > > ```bash theme={null} > curl -X POST "https://api.lightspark.com/grid/2025-10-13/verifications" \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -H "Content-Type: application/json" \ > -d '{ > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001" > }' > ``` > > **Submitted successfully:** > > ```json theme={null} > { > "id": "Verification:019542f5-b3e7-1d02-0000-000000000002", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "verificationStatus": "IN_PROGRESS", > "errors": [], > "createdAt": "2025-10-03T12:00:00Z" > } > ``` > > **Blocked by missing data:** > > ```json theme={null} > { > "id": "Verification:019542f5-b3e7-1d02-0000-000000000001", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "verificationStatus": "RESOLVE_ERRORS", > "errors": [ > { > "resourceId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "type": "MISSING_IDENTITY_DOCUMENT", > "acceptedDocumentTypes": ["PASSPORT", "DRIVERS_LICENSE", "NATIONAL_ID"], > "reason": "Identity document is required" > } > ], > "createdAt": "2025-10-03T12:00:00Z" > } > ``` > > **4. Track the decision** > > Track terminal `kycStatus` transitions via the `CUSTOMER.KYC_APPROVED` / `CUSTOMER.KYC_REJECTED` webhook (recommended) or by polling `GET /customers/{customerId}`. `CUSTOMER.KYC_PENDING` also fires when the customer is submitted for review — subscribe to it if you want to surface an "under review" state. On `APPROVED`, unlock funding and money movement. > [tab] KYB (business) > > When Grid runs KYB for a business customer, the following information and documents are collected before onboarding completes. Use this list to plan what to gather from the business — provide it via `POST /customers`, `POST /beneficial-owners`, and `POST /documents` (or through the hosted flow). > > #### Business identifying information > > * Entity full legal name > * Doing Business As (DBA) name, if applicable > * Physical address — the principal place of business, local office, or other physical location of the entity opening the account > * Countries of operation > * Identification number — U.S. taxpayer identification number, or, for a foreign business without one, alternative government-issued documentation certifying the existence of the business > > #### Ownership and control structure > > Collected for: > > * **One control person** — a single individual with significant responsibility to control, manage, or direct the legal entity, **and** > * **All beneficial owners** — every individual who owns 25% or more of the legal entity, directly or indirectly. > > For every such individual, provide: > > * Full name > * Date of birth > * Address > * Identification number, by residency: > * **U.S. persons** — Social Security Number (SSN) or Individual Taxpayer Identification Number (ITIN) > * **Non-U.S. persons** — one or more of: ITIN, passport number with country of issuance, alien identification card number, or another government-issued document evidencing nationality or residence and bearing a photograph or similar safeguard > > #### Required documents > > * Company formation and existence documents. For example: > * Certificate of incorporation > * Articles of association > * Proof of ownership and control structure. For example: > * Corporate organization and ownership chart > * Shareholder agreements > * Operating agreements > * Register of members > * Certification of controlling person and beneficial owners > * Proof of address, dated within the last 3 months. For example: > * Utility bill > * Bank statement > * Lease agreement > * Official correspondence > * Tax ID or equivalent identifying-number documents > * For non-U.S. beneficial owners — passport plus one additional government-issued ID. For example: > * National ID > > **1. Create the business customer** > > Call `POST /customers` with `customerType: BUSINESS` and the business information (legal name, country of incorporation, registration number, tax ID, registered address). > > ```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 '{ > "customerType": "BUSINESS", > "platformCustomerId": "b87d2e4a9c13f5b", > "businessInfo": { > "legalName": "Acme Corporation", > "country": "US", > "registrationNumber": "789012345", > "taxId": "123-45-6789", > "incorporatedOn": "2018-03-14", > "businessType": "INFORMATION" > }, > "address": { > "line1": "456 Oak Avenue", > "city": "Seattle", > "state": "WA", > "postalCode": "98101", > "country": "US" > } > }' > ``` > > **2. Register beneficial owners** > > Register each beneficial owner, director, or officer with `POST /beneficial-owners`. Each one is verified individually with their own personal information. > > ```bash theme={null} > curl -X POST "https://api.lightspark.com/grid/2025-10-13/beneficial-owners" \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -H "Content-Type: application/json" \ > -d '{ > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "roles": ["DIRECTOR"], > "ownershipPercentage": 40, > "personalInfo": { > "firstName": "Jane", > "lastName": "Doe", > "birthDate": "1985-06-15", > "nationality": "US", > "address": { > "line1": "123 Pine Street", > "city": "Seattle", > "state": "WA", > "postalCode": "98101", > "country": "US" > }, > "idType": "SSN", > "identifier": "123-45-6789" > } > }' > ``` > > **3. Upload supporting documents** > > Upload business documents (registration, articles of incorporation) and any documents required for individual beneficial owners with `POST /documents`. Reference the company by `customerId` and each owner by `beneficialOwnerId`. > > ```bash theme={null} > curl -X POST "https://api.lightspark.com/grid/2025-10-13/documents" \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -F "documentHolder=BeneficialOwner:019542f5-b3e7-1d02-0000-00000000abcd" \ > -F "documentType=PASSPORT" \ > -F "documentNumber=B98765432" \ > -F "issuingAuthority=U.S. Department of State" \ > -F "country=US" \ > -F "file=@./owner-passport.jpg" > ``` > > **4. Submit for verification** > > Call `POST /verifications` to submit the business for review. The response includes a `verificationStatus`. If beneficial owners, fields, or documents are missing, `verificationStatus` is `RESOLVE_ERRORS` and the `errors` array tells you exactly what to collect — note that `resourceId` may point at either the customer or a specific beneficial owner. > > ```bash theme={null} > curl -X POST "https://api.lightspark.com/grid/2025-10-13/verifications" \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -H "Content-Type: application/json" \ > -d '{ > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001" > }' > ``` > > **Blocked by missing data:** > > ```json theme={null} > { > "id": "Verification:019542f5-b3e7-1d02-0000-000000000001", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "verificationStatus": "RESOLVE_ERRORS", > "errors": [ > { > "resourceId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "type": "MISSING_FIELD", > "field": "customer.address.line1", > "reason": "Business address line 1 is required" > }, > { > "resourceId": "BeneficialOwner:019542f5-b3e7-1d02-0000-000000000002", > "type": "MISSING_FIELD", > "field": "personalInfo.birthDate", > "reason": "Date of birth is required for beneficial owners" > } > ], > "createdAt": "2025-10-03T12:00:00Z" > } > ``` > > **Submitted successfully:** > > ```json theme={null} > { > "id": "Verification:019542f5-b3e7-1d02-0000-000000000002", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "verificationStatus": "IN_PROGRESS", > "errors": [], > "createdAt": "2025-10-03T12:00:00Z" > } > ``` > > **5. Track the decision** > > Track terminal `kycStatus` transitions via the `CUSTOMER.KYB_APPROVED` / `CUSTOMER.KYB_REJECTED` webhook (recommended) or by polling `GET /customers/{customerId}`. `CUSTOMER.KYB_PENDING` also fires when the business is submitted for review — subscribe to it if you want to surface an "under review" state. On `APPROVED`, unlock funding and money movement. ## Monitor verification status After a customer completes the KYC/KYB verification process, you'll receive webhook notifications about their KYC status. These notifications are sent to your configured webhook endpoint. > [!NOTE] > > For regulated platforms, customers are created with `APPROVED` KYC status by default. **Webhook Payload (sent to your endpoint):** ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000020", "type": "CUSTOMER.KYC_APPROVED", "timestamp": "2025-07-21T17:32:28Z", "data": { "id": "Customer:019542f5-b3e7-1d02-0000-000000000001", "platformCustomerId": "9f84e0c2a72c4fa", "customerType": "INDIVIDUAL", "umaAddress": "$john.doe@uma.domain.com", "kycStatus": "APPROVED", "fullName": "John Michael Doe", "birthDate": "1990-01-15", "nationality": "US", "address": { "line1": "123 Main Street", "line2": "Apt 4B", "city": "Seattle", "state": "WA", "postalCode": "98101", "country": "US" }, "createdAt": "2025-07-21T17:32:28Z", "updatedAt": "2025-07-21T17:32:28Z", "isDeleted": false } } ``` **Webhook Headers:** * `Content-Type: application/json` * `X-Grid-Signature: {"v": "1", "s": "base64_signature..."}` **id** `string` *required* Unique identifier for this webhook delivery. Use this for idempotency to prevent processing duplicate webhooks. **type** `string` *required* Status-specific event type. KYC/KYB webhooks use `CUSTOMER.*` types: * `CUSTOMER.KYC_APPROVED`: Individual customer verification completed successfully * `CUSTOMER.KYC_REJECTED`: Individual customer verification was rejected * `CUSTOMER.KYC_PENDING`: Individual customer is awaiting review * `CUSTOMER.KYB_APPROVED` / `CUSTOMER.KYB_REJECTED` / `CUSTOMER.KYB_PENDING`: business equivalents (only fires for `customerType: BUSINESS`) **data** `object` *required* The full customer resource object, same as the corresponding `GET /customers/{id}` endpoint would return. Includes all customer fields such as `id`, `kycStatus`, `fullName`, `birthDate`, `nationality`, `address`, etc. > [!NOTE] > > `CUSTOMER.KYC_PENDING` (or `CUSTOMER.KYB_PENDING`) fires when a customer enters `kycStatus: PENDING` (for example, after they are submitted for review). Final outcomes use `CUSTOMER.KYC_APPROVED` / `CUSTOMER.KYC_REJECTED` (and the `CUSTOMER.KYB_*` siblings for business customers). > [accordion] Webhook Implementation Example > > ```javascript theme={null} > // Example webhook handler for KYC status updates. > // CUSTOMER.KYC_APPROVED and CUSTOMER.KYC_REJECTED are terminal decisions; > // CUSTOMER.KYC_PENDING is an intermediate signal that the customer has been > // submitted for review (useful for surfacing "we're still reviewing" UI). > app.post('/webhooks/kyc-status', async (req, res) => { > const { type, data } = req.body; > > switch (type) { > case 'CUSTOMER.KYC_APPROVED': > // Activate customer account > await activateCustomer(data.id); > await sendWelcomeEmail(data.id); > break; > > case 'CUSTOMER.KYC_REJECTED': > // Notify support and customer > await notifySupport(data.id, 'KYC_REJECTED'); > await sendRejectionEmail(data.id); > break; > > case 'CUSTOMER.KYC_PENDING': > // Intermediate: customer submitted for review, no decision yet > await markCustomerUnderReview(data.id); > break; > > case 'CUSTOMER.KYB_APPROVED': > // Business customer decision: activate > await activateCustomer(data.id); > await sendWelcomeEmail(data.id); > break; > > case 'CUSTOMER.KYB_REJECTED': > // Business customer decision: notify > await notifySupport(data.id, 'KYB_REJECTED'); > await sendRejectionEmail(data.id); > break; > > case 'CUSTOMER.KYB_PENDING': > // Intermediate: business customer submitted for review > await markCustomerUnderReview(data.id); > break; > > default: > // Log unexpected types > console.log(`Unexpected webhook type ${type} for customer ${data.id}`); > } > > res.status(200).send('OK'); > }); > ``` ### Handling verification issues The `CUSTOMER.KYC_*` and `CUSTOMER.KYB_*` events above tell you the decision. The `VERIFICATION.*` events tell you **why a verification is stuck and what to send to unstick it** — they carry the full `Verification` resource, including the `errors` array naming each unmet requirement. | Event | Meaning | What to do | | - | - | - | | `VERIFICATION.RESOLVE_ERRORS` | Blocked on missing, invalid, or rejected data | Resolve every entry in `errors`, then re-submit | | `VERIFICATION.PENDING_MANUAL_REVIEW` | A reviewer is assessing the case | Nothing — wait for the terminal event | | `VERIFICATION.IN_PROGRESS` | Submitted; provider checks are running | Nothing — wait | | `VERIFICATION.APPROVED` | Verification passed | Unlock funding and money movement | | `VERIFICATION.REJECTED` | Verification failed | Surface the outcome; see the non-recoverable errors below | > [!NOTE] > > These fire for both individuals and businesses — the customer type is determined by the customer referenced in `data.customerId`. They are most useful for direct API onboarding, where your integration is responsible for collecting what's missing. In the hosted flow the applicant resolves most issues inside the flow itself, so integrate against `kycStatus` / `kybStatus` there. **Example payload:** ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000031", "type": "VERIFICATION.RESOLVE_ERRORS", "timestamp": "2025-08-15T14:32:00Z", "data": { "id": "Verification:019542f5-b3e7-1d02-0000-000000000011", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "verificationStatus": "RESOLVE_ERRORS", "errors": [ { "resourceId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "type": "MISSING_PROOF_OF_ADDRESS_DOCUMENT", "acceptedDocumentTypes": ["UTILITY_BILL", "BANK_STATEMENT"], "reason": "Proof of address document is required" } ], "createdAt": "2025-08-15T14:00:00Z" } } ``` ### Reading an error **resourceId** `string` *required* The resource to fix — a `Customer`, a `BeneficialOwner`, or a `Document`. On a business verification this is what tells you *whose* data is incomplete, since one verification covers the company and every owner. **type** `string` *required* The error category. Drives what you do next — see the remediation table below. **field** `string` Dot-notation path to the offending field (for example, `customer.address.line1`). Present for `MISSING_FIELD` and `INVALID_FIELD`. **acceptedDocumentTypes** `array` The document types that would satisfy the requirement — upload any one of them. Present on the document-related error types. **reason** `string` *required* Human-readable description of the issue. Safe to log; write your own customer-facing copy rather than surfacing this text directly. ### Resolving each error type | Error types | How to resolve | | - | - | | `MISSING_FIELD`, `INVALID_FIELD` | `PATCH` the resource named by `resourceId` (`/customers/{id}` or `/beneficial-owners/{id}`), setting the path given in `field` | | `MISSING_LEGAL_PRESENCE_DOCUMENT`, `MISSING_CONTROL_STRUCTURE_DOCUMENT`, `MISSING_OWNERSHIP_STRUCTURE_DOCUMENT`, `MISSING_PROOF_OF_ADDRESS_DOCUMENT`, `MISSING_IDENTITY_DOCUMENT`, `MISSING_GOOD_STANDING_DOCUMENT` | `POST /documents` with any type listed in `acceptedDocumentTypes` | | `INVALID_DOCUMENT`, `EXPIRED_DOCUMENT`, `POOR_QUALITY_DOCUMENT`, `UNREADABLE_DOCUMENT`, `INCOMPLETE_DOCUMENT`, `WRONG_DOCUMENT_TYPE`, `DOCUMENT_VERIFICATION_FAILED` | Collect a replacement — current, legible, complete, and of an accepted type — and upload it with `POST /documents` | | `MISSING_BENEFICIAL_OWNER`, `MISSING_CONTROL_PERSON` | `POST /beneficial-owners` for the missing individual. Every owner at 25% or more, plus one control person, must be registered | | `SUSPECTED_FRAUD_DOCUMENT`, `APPLICANT_SANCTIONED`, `APPLICANT_FRAUD`, `APPLICANT_CRIMINAL_RECORD`, `APPLICANT_REJECTED` | Not resolvable by re-submitting. Route these to your compliance process | > [!WARNING] > > The `APPLICANT_*` types and `SUSPECTED_FRAUD_DOCUMENT` are compliance outcomes, not data problems. Re-submitting the same customer will not clear them, and retry loops on these are counterproductive — handle them as a terminal outcome for that customer. ### Re-submitting Once every recoverable error is resolved, call `POST /verifications` again with the same `customerId`. The response carries the updated `verificationStatus` — `RESOLVE_ERRORS` again if anything is still outstanding, so you can iterate without waiting for another webhook. To re-read the state at any point, call `GET /verifications?customerId={customerId}`. > [!NOTE] > > Once a customer reaches `APPROVED` or `REJECTED`, further `POST /verifications` calls return `400`. > [accordion] Webhook Implementation Example > > ```javascript theme={null} > // Handler for VERIFICATION.* events. RESOLVE_ERRORS is the one that needs work: > // every entry in data.errors names a resource, a category, and often the exact > // field or the document types that would satisfy it. > const NON_RECOVERABLE = new Set([ > 'SUSPECTED_FRAUD_DOCUMENT', > 'APPLICANT_SANCTIONED', > 'APPLICANT_FRAUD', > 'APPLICANT_CRIMINAL_RECORD', > 'APPLICANT_REJECTED', > ]); > > app.post('/webhooks/verification-status', async (req, res) => { > const { id, type, data } = req.body; > > // The webhook id is unique per delivery — use it to drop duplicates. > if (await alreadyProcessed(id)) { > return res.status(200).send('OK'); > } > > switch (type) { > case 'VERIFICATION.RESOLVE_ERRORS': { > const blocking = data.errors.filter((e) => NON_RECOVERABLE.has(e.type)); > if (blocking.length > 0) { > await escalateToCompliance(data.customerId, blocking); > break; > } > // Ask the customer for exactly what's outstanding, then re-submit > // with POST /verifications once it's all supplied. > await requestOutstandingItems(data.customerId, data.errors); > break; > } > > case 'VERIFICATION.PENDING_MANUAL_REVIEW': > case 'VERIFICATION.IN_PROGRESS': > await markCustomerUnderReview(data.customerId); > break; > > case 'VERIFICATION.APPROVED': > await activateCustomer(data.customerId); > break; > > case 'VERIFICATION.REJECTED': > await notifySupport(data.customerId, 'VERIFICATION_REJECTED'); > break; > > default: > console.log(`Unexpected webhook type ${type} for ${data.customerId}`); > } > > await recordProcessed(id); > res.status(200).send('OK'); > }); > ``` > [!TIP] > > Only customers with `APPROVED` status can create quotes and process > conversions. *** ## Customer types > [accordion] Individual customers > > For personal conversions and consumer wallets. > > **Required fields:** `fullName`, `birthDate`, `address` > > **Conditionally required:** `email` and `phoneNumber` are required in regions that verify contact information before identity verification (e.g., the EU). > > ```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": "user_12345", > "customerType": "INDIVIDUAL", > "fullName": "Alice Johnson", > "email": "alice@example.com", > "birthDate": "1990-01-15", > "address": {...} > }' > ``` > [accordion] Business customers > > For corporate conversions and business accounts. > > **Required fields:** `customerType`, plus `businessInfo.legalName` and `businessInfo.country`. `businessInfo.taxId` and `businessInfo.incorporatedOn` are also required when the customer's currency uses [direct customer-owned accounts](/payouts-and-b2b/onboarding/configuring-customers#bank-account-information), and can be omitted when the platform funds customers from an omnibus FBO account. Most providers also require `address` for KYB. > > ```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": "biz_67890", > "customerType": "BUSINESS", > "email": "finance@acme.com", > "businessInfo": { > "legalName": "Acme Corporation", > "country": "US", > "taxId": "12-3456789", > "incorporatedOn": "2018-03-14", > "businessType": "INFORMATION" > }, > "address": {...} > }' > ``` *** ## Next steps ![](/images/icons/wallet1.svg) Internal Accounts Fund crypto for off-ramp conversions [Open](/ramps/accounts/internal-accounts) ![](/images/icons/bank.svg) External Accounts Set up wallet destinations [Open](/ramps/accounts/external-accounts) ![](/images/icons/arrow-left-right.svg) Fiat-to-Crypto Build conversion flows [Open](/ramps/conversion-flows/fiat-crypto-conversion) ![](/images/icons/code.svg) API Reference Complete customer API docs [Open](/api-reference) # Implementation Overview Source: https://agents-docs.lightspark.com/ramps/onboarding/implementation-overview This page gives you a 10,000‑ft view of an end‑to‑end Grid implementation for on and off-ramps. It is intentionally generalized to cover both on-ramp (fiat → crypto) and off-ramp (crypto → fiat) flows. The detailed guides that follow provide concrete fields, edge cases, and step‑by‑step instructions. > [!NOTE] > > This overview highlights the main building blocks: platform setup, customer > onboarding, account management, conversion flows, reconciliation, sandbox > testing, and go‑live enablement. ## Platform configuration Configure your platform once before building user flows. * Provide webhook endpoints for transaction and conversion status notifications * Generate API credentials for Sandbox (and later Production) * Configure supported currencies for conversions (fiat and crypto) * Review settlement methods and supported crypto destinations ## Onboarding customers Onboard customers who will use ramp services. There are two patterns: * Regulated platforms can directly create customers by providing minimal KYC data via API * Unregulated entities should request a KYC link and embed the hosted KYC flow; once completed, the customer can transact You'll need to persist the Grid customer IDs for use in conversion flows. ## Account management Set up accounts for funding and receiving conversions. ### Internal Accounts * Platform and customer internal accounts hold fiat or crypto balances * Used for pre-funded off-ramp scenarios (crypto → fiat) * Fund via ACH, wire, or crypto deposits ### External Accounts * Register crypto wallets (Spark, Bitcoin addresses) for on-ramp destinations * Register bank accounts for off-ramp destinations * Capture required beneficiary and account information > [!TIP] > > On-ramps typically deliver to external crypto wallets, while off-ramps > typically deliver to external bank accounts. ## On-ramp flow (Fiat → Crypto) Enable users to convert fiat currency to cryptocurrency. ### Quote Creation * Create a quote specifying source (fiat) and destination (crypto wallet) * Lock exchange rate and receive payment instructions * Quote includes reference code for payment matching ### Just-in-Time Funding * User sends fiat payment to provided bank account details * Include reference code in payment memo for matching * Grid detects payment and automatically executes conversion ### Crypto Delivery * Grid converts fiat to crypto at locked rate * Crypto delivered to specified wallet (Spark, Bitcoin, etc.) * Webhook notification confirms delivery > [!NOTE] > > For JIT-funded quotes, do NOT call the execute endpoint. Grid automatically > processes the conversion when it receives your payment. ## Off-ramp flow (Crypto → Fiat) Enable users to convert cryptocurrency to fiat currency. ### Pre-funding * Customer or platform holds crypto in internal accounts * Balances can be funded via crypto deposits (Lightning, on-chain) ### Quote and Execution * Create a quote specifying source (crypto account) and destination (bank account) * Lock exchange rate and fees * Execute the quote to initiate conversion * Grid converts crypto to fiat and delivers to bank account ### Fiat Delivery * Fiat delivered via local banking rails * Settlement times vary by destination (instant to 1-3 business days) * Webhook notification confirms delivery ## Self-custody wallet integration Support for sending crypto to user-controlled wallets. * **Spark wallets**: Lightning-compatible wallets for instant, low-fee Bitcoin transfers * **Validation**: Validate wallet addresses before creating external accounts * **Limits**: Verify minimum and maximum amounts for crypto transfers * **Monitoring**: Track transfer completion via webhooks > [!TIP] > > Spark wallets are recommended for crypto delivery as transfers complete within > seconds with minimal fees. ## Reconciling transactions Implement operational processes to keep your ledger in sync. * Process webhooks idempotently; map statuses (pending, processing, completed, failed) * Tie transactions back to quotes and customers * Track conversion rates and fees for accurate accounting * Query for transactions by date range or customer as necessary ## Testing in Sandbox Use Sandbox to build and validate end‑to‑end without moving real funds. * Simulate fiat funding using `/sandbox/send` endpoint * Simulate crypto deposits using `/sandbox/internal-accounts/{accountId}/fund` endpoint or a `/quotes` call that pulls from a pullable external account * Test quote creation, conversion, and webhook lifecycles * Validate customer onboarding flows with test data See the [Sandbox Testing](../platform-tools/sandbox-testing.mdx) page for more details. ## Enabling Production When you're ready to go live: * Ensure adequate funding mechanisms are in place (for off-ramps) * Confirm webhook security, monitoring, and alerting are configured * Review rate limits, error handling, and idempotency * Test conversion flows thoroughly in Sandbox * Run final UAT in Sandbox, then request Production access from your Lightspark contact > [!TIP] > > Contact your Lightspark representative to enable Production and begin processing real conversions. ## Contact us If you need assistance with Grid, [contact our team](https://www.lightspark.com/contact). # Platform Configuration Source: https://agents-docs.lightspark.com/ramps/onboarding/platform-configuration This guide explains how to configure your platform for ramp operations: get API credentials, configure webhooks, and set up supported currencies for conversions. ## API credentials and authentication Create API credentials in the Grid dashboard. Credentials are scoped to an environment (Sandbox or Production) and cannot be used across environments. * **Authentication**: Use HTTP Basic Auth with your API key and secret in the `Authorization` header * **Environment isolation**: Sandbox keys only work against Sandbox; Production keys only work against Production > [!TIP] > > Never share or expose your API secret. Rotate credentials periodically and > restrict access to authorized systems only. ### Example: HTTP Basic Auth ```bash theme={null} # Using cURL's Basic Auth shorthand (-u): curl -sS -X GET 'https://api.lightspark.com/grid/2025-10-13/config' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` ## Base API path The base API path is consistent across environments; your credentials determine which environment you're accessing. **Base URL**: `https://api.lightspark.com/grid/2025-10-13` > [!NOTE] > > The same base URL is used for both Sandbox and Production. Your API keys > determine which environment processes your requests. ## Supported currencies During onboarding, configure the currencies your platform will support for conversions. Grid supports: ### Fiat Currencies * **USD** (United States Dollar) - Primary fiat currency * **EUR** (Euro) * **GBP** (British Pound) * **MXN** (Mexican Peso) * And more regional currencies ### Cryptocurrencies * **Bitcoin** (BTC) via Spark, L1, or Lightning Network * **Stablecoins** You can add or remove supported currencies anytime in the Grid dashboard. For pre-funded models, Grid automatically creates internal accounts for each supported currency. > [!TIP] > > Start with USD and BTC for the simplest on-ramp and off-ramp implementation, > then add additional currencies as needed. ## Webhooks and notifications Configure your webhook endpoint to receive real-time notifications about conversion status, transaction completion, and account balance updates. ### Webhook setup 1. **Create a public HTTPS endpoint** to receive webhook notifications 2. **Configure the endpoint URL** in the Grid dashboard 3. **Verify webhook signatures** using the Grid public key (provided in dashboard) 4. **Respond with 2xx status codes** to acknowledge receipt 5. **Process events idempotently** to handle duplicate deliveries > [!WARNING] > > For development, use reverse proxies like ngrok to expose local endpoints. > Never use them in production. ### Webhook signature verification Webhooks use asymmetric (public/private key) signatures for security: * Verify the `X-Grid-Signature` header against the exact request body * Use the Grid public key from your dashboard * Reject webhooks with invalid signatures > [!NOTE] > > The public key for verification is shown in the dashboard. Rotate keys when > instructed by the Lightspark team. ### Test your webhook endpoint Use the webhook test endpoint to verify your endpoint configuration: ```bash theme={null} curl -sS -X POST 'https://api.lightspark.com/grid/2025-10-13/webhooks/test' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Example test webhook payload:** ```json theme={null} { "test": true, "timestamp": "2025-10-03T14:32:00Z", "webhookId": "Webhook:019542f5-b3e7-1d02-0000-000000000001", "type": "TEST" } ``` > [!TIP] > > If your endpoint receives the test webhook and responds with a 2xx status > code, your webhook configuration is working correctly. ## Conversion limits Configure minimum and maximum amounts for conversions per currency: * **Minimum amounts**: Prevent uneconomical micro-conversions * **Maximum amounts**: Manage risk and liquidity requirements * **Per-transaction limits**: Configurable in the dashboard ## Retrieve platform configuration You can retrieve your current platform configuration programmatically: ```bash theme={null} curl -sS -X GET 'https://api.lightspark.com/grid/2025-10-13/config' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` **Response example:** ```json theme={null} { "id": "PlatformConfig:019542f5-b3e7-1d02-0000-000000000003", "webhookEndpoint": "https://api.example.com/webhooks/grid", "supportedCurrencies": [ { "currencyCode": "USD", "minAmount": 100, "maxAmount": 1000000, "enabledTransactionTypes": ["OUTGOING", "INCOMING"] }, { "currencyCode": "BTC", "minAmount": 1000, "maxAmount": 10000000, "enabledTransactionTypes": ["OUTGOING", "INCOMING"] } ], "createdAt": "2025-09-01T12:30:45Z", "updatedAt": "2025-10-01T10:00:00Z" } ``` ## Update platform configuration Update your platform configuration using the PATCH endpoint: ```bash theme={null} curl -sS -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.mycompany.com/webhooks/grid", "supportedCurrencies": [ { "currencyCode": "USD", "minAmount": 100, "maxAmount": 1000000, "enabledTransactionTypes": ["OUTGOING", "INCOMING"] }, { "currencyCode": "BTC", "minAmount": 1000, "maxAmount": 10000000, "enabledTransactionTypes": ["OUTGOING", "INCOMING"] } ] }' ``` > [!WARNING] > > Configuration changes take effect immediately. Test changes in Sandbox before > applying to Production. # Postman Collection Source: https://agents-docs.lightspark.com/ramps/platform-tools/postman-collection > [!NOTE] > > Use our hosted Postman collection to explore endpoints and send test requests quickly. ![](/images/icons/IconPostman.svg) Open Postman Collection Launch the collection in Postman. [Open](https://www.postman.com/lightspark-dev/lightspark-grid/collection/adligoh/grid-api?action=share\&source=copy-link\&creator=49207478) # Sandbox Testing Source: https://agents-docs.lightspark.com/ramps/platform-tools/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 > [!NOTE] > > Sandbox is perfect for development, testing, and demonstrating ramp > functionality before going live. ## 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. ### 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` ## Testing on-ramps (Fiat → Crypto) Simulate the complete on-ramp flow in sandbox: > [!NOTE] > > This walkthrough assumes a customer who is approved at creation — a regulated sandbox platform, or an unregulated one with **Skip verification paperwork** turned on in the dashboard's Configuration. On the default unregulated path a new customer stays `UNVERIFIED` until you submit a complete verification, so run through [KYC/KYB verification](/api-reference/sandbox-testing#kyckyb-verification) before Step 2. ### 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": "Seattle", "state": "WA", "postalCode": "98101", "country": "US" } }' ``` > [!TIP] > > A `fullName` not ending in `001`, `002`, or `003` yields an approved customer rather than a real review. Under the configuration above, `Alice Test` is `APPROVED` at creation and ready for Step 2. ### 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. ### 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", "direction": "DEBIT", "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. > [!TIP] > > You'll receive an `INTERNAL_ACCOUNT.BALANCE_UPDATED` webhook showing the updated balance. ### 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": "USD_ACCOUNT", "accountNumber": "123456001", "routingNumber": "021000021", "bankAccountType": "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, and the meaning depends on how the account is used. This account is the destination of the off-ramp quote below, so the quote patterns apply: **002** (quote execution failed), **003** (long payment), **004** (counterparty delivery failed), **005** (receiving bank returned the payment), **007** (payout and refund failed). Any other ending succeeds normally. See "Quote destination test patterns" below for the full table. ### 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" ``` > [!NOTE] > > In sandbox, off-ramp conversions complete instantly. In production, bank > settlement may take 1-3 business days. ## 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`. 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. 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 | ### Quote destination test patterns 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` | | **007** | Payout and refund failed | | **010** | Payout rejected by compliance screening — fails with `failureReason: COMPLIANCE_REJECTED` | | **011** | Invalid recipient account details — fails with `failureReason: ACCOUNT_INVALID` | | **012** | Recipient account cannot receive the payout — fails with `failureReason: ACCOUNT_CANNOT_RECEIVE` | | **013** | Transfer limit exceeded — fails with `failureReason: LIMIT_EXCEEDED` | | **Any other** | Successful payment | One suffix fails before any payment starts. Use it to test a corridor that is paused for maintenance: | Suffix | Behavior | | - | - | | **014** | Corridor paused for maintenance — the account lookup and the quote fail with `403 CORRIDOR_UNDER_MAINTENANCE`. Other accounts in the same currency work as normal | 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. ## 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 | ## 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. The accounts below are the off-ramp quote's destination, so the quote suffixes apply: **1. Test quote execution failure (002):** ```bash theme={null} # Create account with the quote-execution-failure 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": "USD_ACCOUNT", "accountNumber": "000000002", "routingNumber": "021000021", "bankAccountType": "CHECKING", "beneficiary": { "beneficiaryType": "INDIVIDUAL", "fullName": "Test User" } } }' # Execute an off-ramp quote to this account curl -X POST 'https://api.lightspark.com/grid/2025-10-13/quotes/{quoteId}/execute' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" # The transaction fails on the way to settlement with # failureReason: QUOTE_EXECUTION_FAILED ``` **2. Test a long-running payment (003):** ```bash theme={null} # Create account with the long-payment pattern curl -X POST 'https://api.lightspark.com/grid/2025-10-13/customers/external-accounts' \ -d '{"customerId": "Customer:sandbox001", "accountNumber": "000000003", ...}' # Execute a quote to this account - it stays PROCESSING for about # six minutes before completing, so you can exercise pending-state handling ``` **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 '{ "customerId": "Customer:sandbox001", "currency": "BTC", "accountInfo": { "accountType": "SPARK_WALLET", "address": "invalid_address" } }' # Response: 400 Bad Request with validation error ``` ## 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 stamp the exact returned `payloadToSign` string 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 [Encrypt the OTP code](/global-accounts/client-keys#encrypt-the-otp-code-email_otp-only) 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. **1. Create a WebAuthn credential** Generate your own WebAuthn registration challenge and call `navigator.credentials.create()`. **2. Register the passkey** Register the passkey with `POST /auth/credentials`, passing the challenge and attestation returned by the browser. **3. Request a challenge** Reauthenticate with `POST /auth/credentials/{id}/challenge`, passing the compressed P-256 `clientPublicKey` (lowercase hex) whose private key becomes the session signing key. **4. Run the browser assertion** Pass the returned `challenge` into `navigator.credentials.get()` using the returned `credentialId` in `allowCredentials`. **5. Verify the assertion** Verify with `POST /auth/credentials/{id}/verify`, passing the browser assertion and echoing `Request-Id` from the challenge response. The sandbox validates the registered credential ID, WebAuthn challenge, origin/RP binding, user-presence bit, assertion signature, and signature counter. A successful verify response returns the session, just like production. The private key matching the `clientPublicKey` is the session signing key. ```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": "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" }' # 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": "..." } }' ``` ### 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 the lowercase hex SHA-256 digest of `clientPublicKey`, hashed as the exact string sent in the verify request. That string is the compressed public key in lowercase hex: 66 characters starting with `02` or `03`. ```bash theme={null} export PUBLIC_KEY="02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31" OIDC_TOKEN=$(node - <<'NODE' const crypto = require("crypto"); const publicKey = process.env.PUBLIC_KEY || "02f45f2a22c908b9ce09a7150e514afd24627c401c38a4afc164e1ea783adaaa31"; 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. ### Wallet signature header For `PASSKEY` and `OAUTH` credentials and session refresh, the private key matching the `clientPublicKey` you supplied is the session signing key. For `EMAIL_OTP`, the TEK private key you generated for the encrypted OTP flow is the session signing key. 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. ```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. ## 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 # Webhooks Source: https://agents-docs.lightspark.com/ramps/platform-tools/webhooks Receive real-time notifications for ramp conversions, account updates, and transaction status Webhooks provide real-time notifications about ramp operations, allowing you to respond immediately to conversion completions, account balance changes, and transaction status updates. ## Webhook events for ramps Grid sends webhooks for key events in the ramp lifecycle: ### Conversion events > [tab] OUTGOING\_PAYMENT > > Sent when a conversion (on-ramp or off-ramp) changes status. Webhook types use the format `OUTGOING_PAYMENT.` (e.g., `OUTGOING_PAYMENT.COMPLETED`, `OUTGOING_PAYMENT.FAILED`, `OUTGOING_PAYMENT.REFUND_COMPLETED`). > > ```json theme={null} > { > "id": "Webhook:019542f5-b3e7-1d02-0000-000000000030", > "type": "OUTGOING_PAYMENT.COMPLETED", > "timestamp": "2025-10-03T15:03:00Z", > "data": { > "id": "Transaction:019542f5-b3e7-1d02-0000-000000000025", > "status": "COMPLETED", > "type": "OUTGOING", > "direction": "DEBIT", > "sentAmount": { > "amount": 10000, > "currency": { "code": "USD", "decimals": 2 } > }, > "receivedAmount": { > "amount": 95000, > "currency": { "code": "BTC", "decimals": 8 } > }, > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "settledAt": "2025-10-03T15:02:30Z", > "exchangeRate": 9.5, > "quoteId": "Quote:019542f5-b3e7-1d02-0000-000000000006", > "paymentRail": "ACH", > "railSelectionMode": "AUTO", > "expectedSettlementAt": "2025-10-04T15:00:00Z", > "settlementTimelineSeconds": 86400 > } > } > ``` > > > [!TIP] > > > > Use this webhook to update your UI, credit customer accounts, and trigger post-conversion workflows. See the [Transaction Lifecycle](/platform-overview/core-concepts/transaction-lifecycle) guide for all status transitions and refund handling. > [tab] INTERNAL\_ACCOUNT.BALANCE\_UPDATED > > Sent when internal account balances change (deposits, conversions, withdrawals). > > ```json theme={null} > { > "id": "Webhook:019542f5-b3e7-1d02-0000-000000000007", > "type": "INTERNAL_ACCOUNT.BALANCE_UPDATED", > "timestamp": "2025-10-03T15:03:00Z", > "data": { > "id": "InternalAccount:btc456", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "type": "INTERNAL_CRYPTO", > "status": "ACTIVE", > "balance": { > "amount": 5000000, > "currency": { "code": "BTC", "name": "Bitcoin", "symbol": "₿", "decimals": 8 } > }, > "totalBalance": { > "amount": 5000000, > "currency": { "code": "BTC", "name": "Bitcoin", "symbol": "₿", "decimals": 8 } > }, > "fundingPaymentInstructions": [], > "createdAt": "2025-08-01T10:00:00Z", > "updatedAt": "2025-10-03T15:03:00Z" > } > } > ``` > > > [!NOTE] > > > > Critical for tracking crypto deposits (for off-ramps) and fiat balance changes. > [tab] INTERNAL\_ACCOUNT.STATUS\_UPDATED > > Sent when the status of an internal account changes (e.g., PENDING → ACTIVE, ACTIVE → FROZEN). > > ```json theme={null} > { > "id": "Webhook:019542f5-b3e7-1d02-0000-000000000008", > "type": "INTERNAL_ACCOUNT.STATUS_UPDATED", > "timestamp": "2025-10-03T15:03:00Z", > "data": { > "id": "InternalAccount:btc456", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "type": "INTERNAL_CRYPTO", > "status": "FROZEN", > "balance": { > "amount": 5000000, > "currency": { "code": "BTC", "name": "Bitcoin", "symbol": "₿", "decimals": 8 } > }, > "totalBalance": { > "amount": 5000000, > "currency": { "code": "BTC", "name": "Bitcoin", "symbol": "₿", "decimals": 8 } > }, > "fundingPaymentInstructions": [], > "createdAt": "2025-08-01T10:00:00Z", > "updatedAt": "2025-10-03T15:03:00Z" > } > } > ``` > > > [!WARNING] > > > > Account status values: `PENDING` (provisioning), `ACTIVE` (ready for payments), `CLOSED` (customer-initiated), `FROZEN` (compliance/fraud hold), `FAILED` (provisioning failed). Frozen and failed accounts cannot send or receive payments. > [tab] CUSTOMER.KYC\_\* > > Sent when customer KYC verification completes (required before conversions). Business customers emit the `CUSTOMER.KYB_*` siblings. > > ```json theme={null} > { > "id": "Webhook:019542f5-b3e7-1d02-0000-000000000020", > "type": "CUSTOMER.KYC_APPROVED", > "timestamp": "2025-10-03T14:32:00Z", > "data": { > "id": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "platformCustomerId": "user_12345", > "customerType": "INDIVIDUAL", > "kycStatus": "APPROVED", > "fullName": "Jane Doe", > "createdAt": "2025-07-21T17:32:28Z", > "updatedAt": "2025-10-03T14:32:00Z" > } > } > ``` > > > [!TIP] > > > > Enable ramp access immediately when KYC status changes to `APPROVED`. ## Webhook configuration Configure your webhook endpoint in the Grid dashboard or via API: ```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.com/webhooks/grid" }' ``` > [!WARNING] > > Your webhook endpoint must be publicly accessible over HTTPS and respond > within 30 seconds. ## Webhook verification Always verify webhook signatures to ensure authenticity: ### Verification steps **1. Extract signature header** Get the `X-Grid-Signature` header from the webhook request. ```javascript theme={null} const signature = req.headers['x-grid-signature']; ``` **2. Get public key** Retrieve the Grid public key from your dashboard (provided during onboarding). ```javascript theme={null} const publicKey = process.env.GRID_PUBLIC_KEY; ``` **3. Verify signature** Use the public key to verify the signature against the raw request body. ```javascript theme={null} const crypto = require('crypto'); function verifyWebhookSignature(payload, signature, publicKey) { const verify = crypto.createVerify('SHA256'); verify.update(payload); verify.end(); return verify.verify(publicKey, signature, 'base64'); } // In your webhook handler const rawBody = JSON.stringify(req.body); const isValid = verifyWebhookSignature(rawBody, signature, publicKey); if (!isValid) { return res.status(401).json({ error: 'Invalid signature' }); } ``` > [!NOTE] > > The signature is created using secp256r1 (P-256) asymmetric cryptography with > SHA-256 hashing. ## Handling ramp webhooks ### On-ramp completion Handle successful fiat-to-crypto conversions: ```javascript theme={null} app.post("/webhooks/grid", async (req, res) => { // Verify signature if (!verifySignature(req.body, req.headers["x-grid-signature"])) { return res.status(401).end(); } const { type, data } = req.body; if (type === "OUTGOING_PAYMENT.COMPLETED") { // On-ramp completed (USD → BTC) if ( data.sentAmount.currency.code === "USD" && data.receivedAmount.currency.code === "BTC" ) { await db.transactions.create({ userId: data.customerId, type: "ON_RAMP", amountUsd: data.sentAmount.amount, amountBtc: data.receivedAmount.amount, rate: data.exchangeRate, status: "COMPLETED", completedAt: new Date(data.settledAt), }); await sendNotification(data.customerId, { title: "Bitcoin purchased!", message: `You received ${formatBtc(data.receivedAmount.amount)} BTC`, }); } } res.status(200).json({ received: true }); }); ``` ### Off-ramp completion Handle successful crypto-to-fiat conversions: ```javascript theme={null} if (type === "OUTGOING_PAYMENT.COMPLETED") { // Off-ramp completed (BTC → USD) if ( data.sentAmount.currency.code === "BTC" && data.receivedAmount.currency.code === "USD" ) { await db.transactions.create({ userId: data.customerId, type: "OFF_RAMP", amountBtc: data.sentAmount.amount, amountUsd: data.receivedAmount.amount, rate: data.exchangeRate, status: "COMPLETED", bankAccountId: data.destination.accountId, completedAt: new Date(data.settledAt), }); await sendNotification(data.customerId, { title: "Cash out completed!", message: `$${formatUsd(data.receivedAmount.amount)} sent to your bank account`, }); } } ``` ### Balance updates Track crypto deposits for off-ramp liquidity: ```javascript theme={null} if (type === "INTERNAL_ACCOUNT.BALANCE_UPDATED") { const { data } = req.body; const { id: accountId, balance: newBalance } = data; // Crypto deposit detected if ( newBalance.currency.code === "BTC" && newBalance.amount > oldBalance.amount ) { const depositAmount = newBalance.amount - oldBalance.amount; // Record deposit await db.deposits.create({ accountId, currency: "BTC", amount: depositAmount, newBalance: newBalance.amount, }); // Check if user has pending off-ramp const pendingOffRamp = await db.offRamps.findPending(accountId); if (pendingOffRamp && newBalance.amount >= pendingOffRamp.requiredAmount) { // Auto-execute pending off-ramp await executeOffRamp(pendingOffRamp.id); } } } ``` ## Best practices > [accordion] Process webhooks idempotently > > Handle duplicate webhooks gracefully using the envelope `id`: > > ```javascript theme={null} > const { id: webhookId } = req.body; > > // Check if already processed > const existing = await db.webhooks.findUnique({ where: { webhookId } }); > if (existing) { > return res.status(200).json({ received: true }); > } > > // Process webhook > await processRampWebhook(req.body); > > // Record webhook ID > await db.webhooks.create({ > data: { webhookId, processedAt: new Date() } > }); > ``` > [accordion] Use transaction IDs for deduplication > > The transaction lives on `data` and its `id` uniquely identifies the conversion: > > ```javascript theme={null} > const { data: transaction } = req.body; > > // Upsert transaction (handles duplicates) > await db.transactions.upsert({ > where: { gridTransactionId: transaction.id }, > update: { status: transaction.status }, > create: { > gridTransactionId: transaction.id, > customerId: transaction.customerId, > status: transaction.status, > // ... other fields > }, > }); > ``` > [accordion] Implement retry logic > > Grid retries failed webhooks with exponential backoff. Ensure your endpoint can handle retries: > > ```javascript theme={null} > app.post('/webhooks/grid', async (req, res) => { > try { > await processWebhook(req.body); > res.status(200).json({ received: true }); > } catch (error) { > console.error('Webhook processing error:', error); > > // Return 5xx for retryable errors > if (error.retryable) { > res.status(503).json({ error: 'Temporary failure' }); > } else { > // Return 200 for non-retryable to prevent retries > res.status(200).json({ error: error.message }); > } > } > }); > ``` > [accordion] Monitor webhook health > > Track webhook delivery and processing: > > ```javascript theme={null} > // Log webhook metrics > await metrics.increment('webhooks.received', { > type: req.body.type, > status: req.body.transaction?.status, > }); > > // Track processing time > const start = Date.now(); > await processWebhook(req.body); > const duration = Date.now() - start; > > await metrics.histogram('webhooks.processing_time', duration, { > type: req.body.type, > }); > ``` ## Testing webhooks Test webhook handling using the test endpoint: ```bash theme={null} curl -X POST 'https://api.lightspark.com/grid/2025-10-13/webhooks/test' \ -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" ``` This sends a test webhook to your configured endpoint: ```json theme={null} { "test": true, "timestamp": "2025-10-03T14:32:00Z", "id": "Webhook:test001", "type": "TEST" } ``` > [!TIP] > > Verify your endpoint receives the test webhook and responds with a 200 status > code. ## Next steps * [Sandbox Testing](/ramps/platform-tools/sandbox-testing) - Test ramp flows end-to-end * [Platform Configuration](/ramps/onboarding/platform-configuration) - Configure webhook endpoint * [Fiat-to-Crypto Conversion](/ramps/conversion-flows/fiat-crypto-conversion) - Implement conversion flows * [API Reference](/api-reference) - Complete webhook documentation # Quickstart Source: https://agents-docs.lightspark.com/ramps/quickstart Complete guide for converting fiat to crypto (on-ramp) and delivering Bitcoin to a Spark wallet ![Ramps quickstart hero](https://mintcdn.com/agents-docs/qL8kqlSYbvexR8cg/images/heroes/hero-quickstart-ramps-light.svg?fit=max\&auto=format\&n=qL8kqlSYbvexR8cg\&q=85\&s=5f6fe21b7360bde7a8ddebe75980ecdf) This guide walks you through the complete process of converting USD to Bitcoin and sending it to a Spark wallet using just-in-time (JIT) funding. ## Prerequisites Before starting this guide, ensure you have: * A Grid API account with valid authentication credentials * Access to the Grid API endpoints (production or sandbox) * A webhook endpoint configured to receive notifications * A Spark wallet address where the Bitcoin will be delivered ## Overview The on-ramp process consists of the following steps: 1. **Create a customer** via the API 2. **Create an external account** for the destination Spark wallet 3. **Create a quote** for the USD-to-BTC conversion with current exchange rate 4. **Fund the quote** using the provided payment instructions (JIT funding) 5. **Receive webhook notification** confirming Bitcoin delivery to the Spark wallet *** ## Step 1: Customer Onboarding If your platform is a regulated financial institution that already has a KYC/KYB process in place, you can create a customer directly via the API. Unregulated platforms can onboard customers either through the hosted KYC/KYB link flow or by submitting customer data directly through the API — for `INDIVIDUAL` customers (KYC), personal information goes through `POST /customers`; for `BUSINESS` customers (KYB), you also register beneficial owners via `POST /beneficial-owners`. > [tab] Regulated Platforms > > > [!TIP] > > > > **Regulated platforms** have lighter KYC requirements since they handle compliance verification internally. > > The KYC/KYB flow allows you to onboard customers through direct API calls. > > Regulated financial institutions can: > > * **Direct API Onboarding**: Create customers directly via API calls with minimal verification > * **Internal KYC/KYB**: Handle identity verification through your own compliance systems > * **Reduced Documentation**: Only provide essential customer information required by your payment counterparty or service provider. > * **Faster Onboarding**: Streamlined process for known, verified customers > > ### Creating customers via direct API > > For regulated platforms, you can create customers directly through the API without requiring external KYC verification: > > To register a new customer in the system, use the `POST /customers` endpoint: > > ```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": "customer_12345", > "customerType": "INDIVIDUAL", > "fullName": "Jane Doe", > "birthDate": "1992-03-25", > "nationality": "US", > "address": { > "line1": "123 Pine Street", > "city": "Seattle", > "state": "WA", > "postalCode": "98101", > "country": "US" > } > }' > ``` > > The examples below show a more comprehensive set of data. Not all fields are strictly required by the API for customer creation itself, but become necessary based on currency and UMA provider requirements if using UMA. > > > [tab] Individual customer > > > > ```json theme={null} > > { > > "platformCustomerId": "9f84e0c2a72c4fa", > > "customerType": "INDIVIDUAL", > > "fullName": "John Sender", > > "birthDate": "1985-06-15", > > "address": { > > "line1": "Paseo de la Reforma 222", > > "line2": "Piso 15", > > "city": "Ciudad de México", > > "state": "Ciudad de México", > > "postalCode": "06600", > > "country": "MX" > > } > > } > > ``` > > > [tab] Business Customer > > > > ```json theme={null} > > { > > "platformCustomerId": "b87d2e4a9c13f5b", > > "customerType": "BUSINESS", > > "businessInfo": { > > "legalName": "Acme Corporation", > > "country": "US", > > "registrationNumber": "789012345", > > "taxId": "123-45-6789", > > "incorporatedOn": "2018-03-14", > > "businessType": "INFORMATION" > > }, > > "address": { > > "line1": "456 Oak Avenue", > > "line2": "Floor 12", > > "city": "Seattle", > > "state": "WA", > > "postalCode": "98101", > > "country": "US" > > } > > } > > ``` > [tab] Unregulated Platforms > > > [!NOTE] > > > > **Unregulated platforms** rely on Grid to run KYC for individuals and KYB for businesses. You can onboard customers either through the **hosted KYC/KYB link flow** below, or by **submitting customer data directly through the API**. Both paths cover KYC and KYB, produce the same status transitions — on `kycStatus` for individuals, `kybStatus` for businesses — and emit the same `CUSTOMER.KYC_*` / `CUSTOMER.KYB_*` webhooks. You can also mix them: supply what you already hold through the API, then let the hosted flow collect the rest. > > Either path works for unregulated platforms: > > * **Hosted flow**: Redirect customers to a Grid-hosted link (or embed the provider SDK). Covers individuals and businesses alike. Best when you want Grid to handle the entire collection UX. > * **Direct API onboarding**: Collect customer information in your own UI and submit it via the API. For `INDIVIDUAL` customers (KYC), personal information goes through `POST /customers`. For `BUSINESS` customers (KYB), you also register beneficial owners via `POST /beneficial-owners`. Submit for review with `POST /verifications`. > > ### Hosted KYC and KYB Link Flow > > The hosted flow provides a secure, hosted interface where customers complete verification. It serves both customer types — there is no separate KYB link endpoint. `POST /customers/{customerId}/kyc-link` generates the link, and `customerType` selects which flow the provider runs: > > * **`INDIVIDUAL`** — identity verification (KYC), tracked on `kycStatus`. > * **`BUSINESS`** — business verification (KYB), tracked on `kybStatus`. The applicant confirms the company details, uploads the formation, ownership, and proof-of-address documents, and declares the control person and every beneficial owner holding 25% or more. > > The flow is two steps: create the customer with the information you have, then generate the link. The status starts at `UNVERIFIED` and moves to `PENDING` once the customer completes the flow and review begins. > > #### 1. Create the customer > > > [tab] Individual (KYC) > > > > Supply at least `customerType` and any fields you already have. > > > > ```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 '{ > > "customerType": "INDIVIDUAL", > > "platformCustomerId": "9f84e0c2a72c4fa", > > "region": "US", > > "currencies": ["USD", "USDC"], > > "email": "jane.doe@example.com", > > "fullName": "Jane Doe" > > }' > > ``` > > > [tab] Business (KYB) > > > > `customerType: BUSINESS` requires these fields — the request fails without them: > > > > | Field | Notes | > > | - | - | > > | `businessInfo.legalName` | Full legal entity name | > > | `businessInfo.country` | Country of incorporation, ISO 3166-1 alpha-2. Sets the applicant's jurisdiction and the tax-ID format validated against | > > | `businessInfo.taxId` | Validated against `businessInfo.country` | > > | `businessInfo.incorporatedOn` | `YYYY-MM-DD` | > > > > ```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 '{ > > "customerType": "BUSINESS", > > "platformCustomerId": "biz-acme-001", > > "region": "US", > > "currencies": ["USD", "USDC"], > > "email": "finance@acme.com", > > "businessInfo": { > > "legalName": "Acme Corporation, Inc.", > > "country": "US", > > "taxId": "47-1234567", > > "incorporatedOn": "2018-03-14", > > "registrationNumber": "5523041", > > "entityType": "LLC" > > } > > }' > > ``` > > > > Everything else is optional to the schema, but three groups matter in practice: > > > > * **Contact channels** — `email` and/or `phoneNumber`, plus `businessInfo.primaryContactFirstName` and `primaryContactLastName`, are required in regions that verify a named representative before verification begins (for example the EU). > > * **Currency-driven fields** — anything listed in `providerRequiredCustomerFields` for a currency the business will transact in. > > * **Everything the review needs** — `address`, `registrationNumber`, `entityType`, `countriesOfOperation`, `businessType`, `purposeOfAccount`, `sourceOfFunds`, and the expected-activity fields aren't enforced at creation. Whatever you omit, the applicant is asked for in the flow. > > Persist the returned `id` (the Grid customer ID) — you'll need it for the next step. > > > [!TIP] > > > > Business information you supply is prefilled into the hosted flow, so send as much as you have — via `POST /customers` or a later `PATCH /customers/{customerId}` — **before** generating the link. Every field you pass is one the applicant doesn't retype, and one fewer chance for their answer to diverge from your record. > > #### 2. Generate the link > > ```bash theme={null} > curl -X POST "https://api.lightspark.com/grid/2025-10-13/customers/Customer:019542f5-b3e7-1d02-0000-000000000001/kyc-link" \ > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > -H "Content-Type: application/json" \ > -d '{ > "redirectUri": "https://yourapp.com/onboarding-complete" > }' > ``` > > **Response:** > > ```json theme={null} > { > "kycUrl": "https://kyc.lightspark.com/onboard/abc123def456", > "expiresAt": "2027-01-15T14:32:00Z", > "provider": "SUMSUB", > "token": "_act-sbx-jwt-eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." > } > ``` > > > [!TIP] > > > > The response field is named `kycUrl` for both customer types; for a `BUSINESS` customer it opens the KYB flow. For providers that support direct SDK integration (currently SUMSUB), a `token` is also returned — pass it to the provider's web SDK to embed verification in your own UI instead of redirecting. Both update the customer's status identically. > > #### The flow end to end > > **1. Create the customer** > > Call `POST /customers` with `customerType` and any pre-fill fields you have. The returned `id` is the customer's Grid ID. Their status — `kycStatus` for an individual, `kybStatus` for a business — starts at `UNVERIFIED`, becomes `PENDING` while under review, and lands on `APPROVED` or `REJECTED` when the decision is made. (`HOLD` means the customer may be asked to supply more information.) > > **2. Verify contact channels (only where required)** > > Some regions (for example, the EU) require the customer's email and/or phone to be verified before verification can begin. For a business customer, these are the primary contact's details. When this applies, the customer carries a `contactVerification` object listing **only the channels that are required** — for example `{ "email": "PENDING", "phone": "PENDING" }`, or just `{ "email": "PENDING" }` if a provider requires email alone. When the object is absent, no contact verification is required and you can skip this step. > > For each channel that's present, drive a send-then-confirm pair (the provider delivers a one-time code by email/SMS): > > * `POST /customers/{customerId}/verify-email`, then `POST /customers/{customerId}/verify-email/confirm` with `{ "code": "..." }` > * `POST /customers/{customerId}/verify-phone`, then `POST /customers/{customerId}/verify-phone/confirm` with `{ "code": "..." }` > > Re-POST the send endpoint to resend a code. Each channel moves `PENDING` → `VERIFIED`. (`verify-email`/`verify-phone` return `409` for a channel the provider doesn't require.) > > > [!NOTE] > > > > Generating the link before every present channel is `VERIFIED` returns `409`. > > **3. Generate the link** > > Call `POST /customers/{customerId}/kyc-link`. Each call returns a fresh single-use `kycUrl` and `expiresAt`; previously-issued links remain single-use but aren't invalidated. > > > [!NOTE] > > > > The `redirectUri` you pass is embedded in the generated `kycUrl` and is used to automatically return the customer to your application after they complete verification. > > **4. Send the customer through verification** > > Redirect the customer to `kycUrl`, or — if you want to embed the flow directly — initialize the provider's SDK with the returned `token`. A business applicant completes the company details, uploads the company documents, and declares the beneficial owners here. > > > [!WARNING] > > > > The hosted URL is single-use and expires at `expiresAt`. If a customer needs to retry, call the endpoint again to generate a new link. > > **5. Track the decision** > > Reaching your `redirectUri` only means the customer **finished the hosted flow** — not that they were approved. Wait for the final decision in one of two ways: > > * **Webhook (recommended):** Subscribe to `CUSTOMER.KYC_APPROVED` / `CUSTOMER.KYC_REJECTED` (and `CUSTOMER.KYB_APPROVED` / `CUSTOMER.KYB_REJECTED` for business customers) to be notified when the customer reaches a terminal status. `CUSTOMER.KYC_PENDING` (and the `KYB_PENDING` sibling) also fires when the customer is submitted for review — subscribe to it as well if you want to surface an "under review" state to the customer. > * **Polling:** Call `GET /customers/{customerId}` and inspect `kycStatus` (individuals) or `kybStatus` (businesses). > > `GET /verifications?customerId=...` gives the finer-grained `verificationStatus` and `errors` array. That detail drives direct API onboarding; in the hosted flow the customer resolves it inside the flow, so integrate against `kycStatus` / `kybStatus`. > > **6. Handle completion** > > On `APPROVED`, the customer is ready to transact — proceed with account setup and unlock funding. On `REJECTED`, surface the appropriate next step (for example, regenerate the link or request manual review). While the status is `PENDING`, let the customer finish account setup but block funding and money movement. > > > [!WARNING] > > > > Testing the business flow in sandbox: by default the hosted flow collects the full packet, and a `businessInfo.registrationNumber` **not** ending in `001`, `002`, or `003` auto-approves the customer once that submission is complete. With **Skip verification paperwork** on, that suffix resolves before any packet validation, leaving the hosted flow nothing to verify — so it asks for no documents and no beneficial owners. Use a `003` suffix to exercise document and UBO collection. See [Sandbox testing](/api-reference/sandbox-testing). > > ### Direct API Onboarding > > Prefer to collect identity information in your own UI and submit it to Grid yourself? Use the API directly instead of redirecting to a hosted link. The customer's `kycStatus` transitions the same way and you receive the same `CUSTOMER.KYC_APPROVED` / `CUSTOMER.KYC_REJECTED` / `CUSTOMER.KYC_PENDING` (and `CUSTOMER.KYB_*` equivalents) webhooks. > > The shape of the flow depends on the customer type: > > * **KYC (`INDIVIDUAL` customers)** — supply the customer's personal information through the customer endpoint. No beneficial owners are involved. > > * **KYB (`BUSINESS` customers)** — create the business customer, then register its beneficial owners, directors, and officers individually. > > > [tab] KYC (individual) > > > > **1. Create the customer with personal information** > > > > Call `POST /customers` with `customerType: INDIVIDUAL` and the personal information collected from the customer (legal name, date of birth, address, nationality, etc.). The returned `id` is the customer's Grid ID; `kycStatus` starts at `UNVERIFIED` and moves to `PENDING` once you submit for verification. > > > > > [!NOTE] > > > > > > `fullName` must contain both a given name and a family name (for example, `"Jane Doe"`). The name is split into first/last for the verification provider, and a single-word name is rejected at submission. > > > > ```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 '{ > > "customerType": "INDIVIDUAL", > > "platformCustomerId": "9f84e0c2a72c4fa", > > "fullName": "Jane Doe", > > "birthDate": "1985-06-15", > > "nationality": "US", > > "email": "jane.doe@example.com", > > "address": { > > "line1": "123 Pine Street", > > "city": "Seattle", > > "state": "WA", > > "postalCode": "98101", > > "country": "US" > > } > > }' > > ``` > > > > > [!NOTE] > > > > > > Where the customer's provider requires it (for example, EU customers), the customer carries a `contactVerification` object and their email and phone must be `VERIFIED` before you submit for verification. Drive each channel with the send-then-confirm pairs (`verify-email` / `verify-email/confirm` and `verify-phone` / `verify-phone/confirm`) described in the Hosted flow's "Verify email and phone" step. When `contactVerification` is absent, no contact verification is required. > > > > **2. Upload the identity document** > > > > Individual verification requires an identity document — a `PASSPORT` (single image), or a `DRIVERS_LICENSE` / `NATIONAL_ID` / `RESIDENCE_PERMIT` (uploaded as separate `FRONT` and `BACK` images via the `side` field). Some jurisdictions or currencies also request additional documents such as proof of address. Upload each one with `POST /documents` using `multipart/form-data`; identity documents also require `documentNumber` and `issuingAuthority`. Files must be `application/pdf`, `image/jpeg`, or `image/png`, and 10 MB or smaller. A photo of a document has to show all four corners in frame, in focus and in color, or the upload is rejected with `422 DOCUMENT_REJECTED`. > > > > ```bash theme={null} > > curl -X POST "https://api.lightspark.com/grid/2025-10-13/documents" \ > > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > > -F "documentHolder=Customer:019542f5-b3e7-1d02-0000-000000000001" \ > > -F "documentType=PASSPORT" \ > > -F "documentNumber=A12345678" \ > > -F "issuingAuthority=U.S. Department of State" \ > > -F "country=US" \ > > -F "file=@./passport.jpg" > > ``` > > > > **3. Submit for verification** > > > > Call `POST /verifications` to submit the customer for review. The response includes a `verificationStatus`. If anything is missing, `verificationStatus` is `RESOLVE_ERRORS` and the `errors` array describes exactly what to collect before retrying. > > > > ```bash theme={null} > > curl -X POST "https://api.lightspark.com/grid/2025-10-13/verifications" \ > > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > > -H "Content-Type: application/json" \ > > -d '{ > > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001" > > }' > > ``` > > > > **Submitted successfully:** > > > > ```json theme={null} > > { > > "id": "Verification:019542f5-b3e7-1d02-0000-000000000002", > > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > > "verificationStatus": "IN_PROGRESS", > > "errors": [], > > "createdAt": "2025-10-03T12:00:00Z" > > } > > ``` > > > > **Blocked by missing data:** > > > > ```json theme={null} > > { > > "id": "Verification:019542f5-b3e7-1d02-0000-000000000001", > > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > > "verificationStatus": "RESOLVE_ERRORS", > > "errors": [ > > { > > "resourceId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > > "type": "MISSING_IDENTITY_DOCUMENT", > > "acceptedDocumentTypes": ["PASSPORT", "DRIVERS_LICENSE", "NATIONAL_ID"], > > "reason": "Identity document is required" > > } > > ], > > "createdAt": "2025-10-03T12:00:00Z" > > } > > ``` > > > > **4. Track the decision** > > > > Track terminal `kycStatus` transitions via the `CUSTOMER.KYC_APPROVED` / `CUSTOMER.KYC_REJECTED` webhook (recommended) or by polling `GET /customers/{customerId}`. `CUSTOMER.KYC_PENDING` also fires when the customer is submitted for review — subscribe to it if you want to surface an "under review" state. On `APPROVED`, unlock funding and money movement. > > > [tab] KYB (business) > > > > When Grid runs KYB for a business customer, the following information and documents are collected before onboarding completes. Use this list to plan what to gather from the business — provide it via `POST /customers`, `POST /beneficial-owners`, and `POST /documents` (or through the hosted flow). > > > > #### Business identifying information > > > > * Entity full legal name > > * Doing Business As (DBA) name, if applicable > > * Physical address — the principal place of business, local office, or other physical location of the entity opening the account > > * Countries of operation > > * Identification number — U.S. taxpayer identification number, or, for a foreign business without one, alternative government-issued documentation certifying the existence of the business > > > > #### Ownership and control structure > > > > Collected for: > > > > * **One control person** — a single individual with significant responsibility to control, manage, or direct the legal entity, **and** > > * **All beneficial owners** — every individual who owns 25% or more of the legal entity, directly or indirectly. > > > > For every such individual, provide: > > > > * Full name > > * Date of birth > > * Address > > * Identification number, by residency: > > * **U.S. persons** — Social Security Number (SSN) or Individual Taxpayer Identification Number (ITIN) > > * **Non-U.S. persons** — one or more of: ITIN, passport number with country of issuance, alien identification card number, or another government-issued document evidencing nationality or residence and bearing a photograph or similar safeguard > > > > #### Required documents > > > > * Company formation and existence documents. For example: > > * Certificate of incorporation > > * Articles of association > > * Proof of ownership and control structure. For example: > > * Corporate organization and ownership chart > > * Shareholder agreements > > * Operating agreements > > * Register of members > > * Certification of controlling person and beneficial owners > > * Proof of address, dated within the last 3 months. For example: > > * Utility bill > > * Bank statement > > * Lease agreement > > * Official correspondence > > * Tax ID or equivalent identifying-number documents > > * For non-U.S. beneficial owners — passport plus one additional government-issued ID. For example: > > * National ID > > > > **1. Create the business customer** > > > > Call `POST /customers` with `customerType: BUSINESS` and the business information (legal name, country of incorporation, registration number, tax ID, registered address). > > > > ```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 '{ > > "customerType": "BUSINESS", > > "platformCustomerId": "b87d2e4a9c13f5b", > > "businessInfo": { > > "legalName": "Acme Corporation", > > "country": "US", > > "registrationNumber": "789012345", > > "taxId": "123-45-6789", > > "incorporatedOn": "2018-03-14", > > "businessType": "INFORMATION" > > }, > > "address": { > > "line1": "456 Oak Avenue", > > "city": "Seattle", > > "state": "WA", > > "postalCode": "98101", > > "country": "US" > > } > > }' > > ``` > > > > **2. Register beneficial owners** > > > > Register each beneficial owner, director, or officer with `POST /beneficial-owners`. Each one is verified individually with their own personal information. > > > > ```bash theme={null} > > curl -X POST "https://api.lightspark.com/grid/2025-10-13/beneficial-owners" \ > > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > > -H "Content-Type: application/json" \ > > -d '{ > > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > > "roles": ["DIRECTOR"], > > "ownershipPercentage": 40, > > "personalInfo": { > > "firstName": "Jane", > > "lastName": "Doe", > > "birthDate": "1985-06-15", > > "nationality": "US", > > "address": { > > "line1": "123 Pine Street", > > "city": "Seattle", > > "state": "WA", > > "postalCode": "98101", > > "country": "US" > > }, > > "idType": "SSN", > > "identifier": "123-45-6789" > > } > > }' > > ``` > > > > **3. Upload supporting documents** > > > > Upload business documents (registration, articles of incorporation) and any documents required for individual beneficial owners with `POST /documents`. Reference the company by `customerId` and each owner by `beneficialOwnerId`. > > > > ```bash theme={null} > > curl -X POST "https://api.lightspark.com/grid/2025-10-13/documents" \ > > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > > -F "documentHolder=BeneficialOwner:019542f5-b3e7-1d02-0000-00000000abcd" \ > > -F "documentType=PASSPORT" \ > > -F "documentNumber=B98765432" \ > > -F "issuingAuthority=U.S. Department of State" \ > > -F "country=US" \ > > -F "file=@./owner-passport.jpg" > > ``` > > > > **4. Submit for verification** > > > > Call `POST /verifications` to submit the business for review. The response includes a `verificationStatus`. If beneficial owners, fields, or documents are missing, `verificationStatus` is `RESOLVE_ERRORS` and the `errors` array tells you exactly what to collect — note that `resourceId` may point at either the customer or a specific beneficial owner. > > > > ```bash theme={null} > > curl -X POST "https://api.lightspark.com/grid/2025-10-13/verifications" \ > > -u "$GRID_CLIENT_ID:$GRID_CLIENT_SECRET" \ > > -H "Content-Type: application/json" \ > > -d '{ > > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001" > > }' > > ``` > > > > **Blocked by missing data:** > > > > ```json theme={null} > > { > > "id": "Verification:019542f5-b3e7-1d02-0000-000000000001", > > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > > "verificationStatus": "RESOLVE_ERRORS", > > "errors": [ > > { > > "resourceId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > > "type": "MISSING_FIELD", > > "field": "customer.address.line1", > > "reason": "Business address line 1 is required" > > }, > > { > > "resourceId": "BeneficialOwner:019542f5-b3e7-1d02-0000-000000000002", > > "type": "MISSING_FIELD", > > "field": "personalInfo.birthDate", > > "reason": "Date of birth is required for beneficial owners" > > } > > ], > > "createdAt": "2025-10-03T12:00:00Z" > > } > > ``` > > > > **Submitted successfully:** > > > > ```json theme={null} > > { > > "id": "Verification:019542f5-b3e7-1d02-0000-000000000002", > > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > > "verificationStatus": "IN_PROGRESS", > > "errors": [], > > "createdAt": "2025-10-03T12:00:00Z" > > } > > ``` > > > > **5. Track the decision** > > > > Track terminal `kycStatus` transitions via the `CUSTOMER.KYB_APPROVED` / `CUSTOMER.KYB_REJECTED` webhook (recommended) or by polling `GET /customers/{customerId}`. `CUSTOMER.KYB_PENDING` also fires when the business is submitted for review — subscribe to it if you want to surface an "under review" state. On `APPROVED`, unlock funding and money movement. ## Step 2: Create an External Account for the Destination Wallet Register the customer's Spark wallet as an external account so it can be used as a quote destination. ### Request ```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:019542f5-b3e7-1d02-0000-000000000001", "currency": "BTC", "accountInfo": { "accountType": "SPARK_WALLET", "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu" } }' ``` ### Response ```json theme={null} { "id": "ExternalAccount:b23dcbd6-dced-4ec4-b756-3c3a9ea3d456", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "status": "ACTIVE", "currency": "BTC", "accountInfo": { "accountType": "SPARK_WALLET", "address": "spark1pgssyuuuhnrrdjswal5c3s3rafw9w3y5dd4cjy3duxlf7hjzkp0rqx6dj6mrhu" } } ``` > [!TIP] > > External accounts can be reused across multiple quotes. Register once, then reference by ID. ## Step 3: Create a Quote for Fiat-to-Crypto Conversion Create a quote to convert USD to Bitcoin and deliver it to the Spark wallet. The quote will provide the current exchange rate and payment instructions for funding. ### Request ```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:019542f5-b3e7-1d02-0000-000000000001", "currency": "USD" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:b23dcbd6-dced-4ec4-b756-3c3a9ea3d456" }, "lockedCurrencySide": "SENDING", "lockedCurrencyAmount": 10000, "description": "On-ramp: Buy $100 of Bitcoin" }' ``` ### Response ```json theme={null} { "id": "Quote:019542f5-b3e7-1d02-0000-000000000006", "status": "PENDING", "createdAt": "2025-10-03T15:00:00Z", "expiresAt": "2025-10-03T15:05:00Z", "source": { "sourceType": "REALTIME_FUNDING", "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "currency": "USD" }, "destination": { "destinationType": "ACCOUNT", "accountId": "ExternalAccount:b23dcbd6-dced-4ec4-b756-3c3a9ea3d456" }, "sendingCurrency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 }, "receivingCurrency": { "code": "BTC", "name": "Bitcoin", "symbol": "₿", "decimals": 8 }, "totalSendingAmount": 10000, "totalReceivingAmount": 83333, "exchangeRate": 8.3333, "feesIncluded": 250, "platformFeesIncluded": 0, "paymentInstructions": [ { "instructionsNotes": "Include reference code in transfer memo", "accountOrWalletInfo": { "reference": "RAMP-ABC123", "accountType": "USD_ACCOUNT", "accountNumber": "9876543210", "routingNumber": "021000021", "accountHolderName": "Lightspark Payments FBO Customer", "bankName": "JP Morgan Chase" } }, { "accountOrWalletInfo": { "accountType": "SOLANA_WALLET", "assetType": "USDC", "address": "4Nd1m6Qkq7RfKuE5vQ9qP9Tn6H94Ueqb4xXHzsAbd8Wg" } } ] } ``` The quote shows: * **Sending**: \$100.00 USD (including \$2.50 fee) * **Receiving**: 0.00083333 BTC (83,333 satoshis) * **Exchange rate**: 8.3333 sats per USD cent (\~\$120,000 per BTC) * **Quote expires**: In 5 minutes * **Payment instructions**: Bank account details and reference code for funding > [!WARNING] > > For JIT-funded quotes, do NOT call the `/quotes/{quoteId}/execute` endpoint. > Simply fund using the payment instructions, and Grid will automatically > execute the conversion upon receiving your payment. The execute endpoint is > used for quotes with an internal account or pullable external account as the source. *** ## Step 4: Fund the Quote (Just-in-Time) In production, you would initiate a real-time push payment (ACH, RTP, wire, etc.) to the bank account provided in `paymentInstructions`, making sure to include the exact reference code `RAMP-ABC123` in the transfer memo. > [tab] Sandbox (Simulated) > > In Sandbox, you can simulate funding using the `/sandbox/send` endpoint: > > ```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" > }' > ``` > > **Response:** > > ```json theme={null} > { > "id": "Transaction:019542f5-b3e7-1d02-0000-000000000025", > "status": "PROCESSING", > "type": "OUTGOING", > "direction": "DEBIT", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", > "platformCustomerId": "customer_12345", > "source": { > "sourceType": "REALTIME_FUNDING", > "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001" > }, > "destination": { > "destinationType": "ACCOUNT", > "accountId": "ExternalAccount:b23dcbd6-dced-4ec4-b756-3c3a9ea3d456" > }, > "sentAmount": { "amount": 10000, "currency": { "code": "USD", "decimals": 2 } }, > "receivedAmount": { "amount": 83333, "currency": { "code": "BTC", "decimals": 8 } }, > "quoteId": "Quote:019542f5-b3e7-1d02-0000-000000000006" > } > ``` > [tab] Production (Real Funding) > > In production, your customer or platform would initiate a bank transfer to the provided account details, include the reference code `RAMP-ABC123` in the transfer memo, and wait for Grid to detect the incoming payment (typically 1-3 business days for ACH, instant for RTP/wire). You'll receive a webhook notification when Bitcoin is delivered to the Spark wallet. > [!TIP] > > The reference code is critical for matching your payment to the quote. Always > include it exactly as provided in the payment instructions. *** ## Step 5: Receive Completion Webhook Once Grid receives your payment and completes the USD-to-BTC conversion and delivery, you'll receive a webhook notification: ```json theme={null} { "id": "Webhook:019542f5-b3e7-1d02-0000-000000000030", "type": "OUTGOING_PAYMENT.COMPLETED", "timestamp": "2025-10-03T15:03:00Z", "data": { "id": "Transaction:019542f5-b3e7-1d02-0000-000000000025", "status": "COMPLETED", "type": "OUTGOING", "direction": "DEBIT", "sentAmount": { "amount": 10000, "currency": { "code": "USD", "name": "United States Dollar", "symbol": "$", "decimals": 2 } }, "receivedAmount": { "amount": 83333, "currency": { "code": "BTC", "name": "Bitcoin", "symbol": "₿", "decimals": 8 } }, "customerId": "Customer:019542f5-b3e7-1d02-0000-000000000001", "settledAt": "2025-10-03T15:02:30Z", "createdAt": "2025-10-03T15:00:00Z", "description": "On-ramp: Buy $100 of Bitcoin", "exchangeRate": 8.3333, "quoteId": "Quote:019542f5-b3e7-1d02-0000-000000000006", "paymentInstructions": [ { "instructionsNotes": "Include reference code in transfer memo", "accountOrWalletInfo": { "accountType": "USD_ACCOUNT", "accountNumber": "1234567890", "routingNumber": "021000021", "bankName": "Chase Bank", "reference": "REF123456" } } ] } } ``` > [!TIP] > > The customer now has 83,333 satoshis (0.00083333 BTC) in their Spark wallet! *** ## Summary You've successfully completed a fiat-to-crypto on-ramp! Here's what happened: 1. ✅ Created a customer via API 2. ✅ Registered the destination Spark wallet as an external account 3. ✅ Created a quote with exchange rate and payment instructions 4. ✅ Funded the quote using JIT payment (simulated in sandbox) 5. ✅ Bitcoin automatically converted and delivered to Spark wallet ## Next Steps * **Off-ramps**: Learn how to convert crypto to fiat in the [Crypto-to-Fiat guide](/ramps/conversion-flows/fiat-crypto-conversion) * **Self-custody wallets**: Explore advanced wallet integration in the [Self-Custody Wallets guide](/ramps/conversion-flows/self-custody-wallets) * **Webhook verification**: Implement signature verification for security (see [Webhooks guide](/ramps/platform-tools/webhooks)) * **Sandbox testing**: Learn more about testing in the [Sandbox Testing guide](/ramps/platform-tools/sandbox-testing) ## Related Resources * [API Reference](/api-reference) - Complete API documentation * [Implementation Overview](/ramps/onboarding/implementation-overview) - High-level architecture and flow * [Platform Configuration](/ramps/onboarding/platform-configuration) - Configure your platform settings # Core Concepts Source: https://agents-docs.lightspark.com/ramps/terminology Core concepts and terminology for the Grid API There are several key entities in the Grid API: **Platform**, **Customers**, **Internal Accounts**, **External Accounts**, **Quotes**, **Transactions**, and **UMA Addresses**. ![Entity relationships diagram showing platform, customers, internal accounts, external accounts, transactions, and UMA addresses](https://mintcdn.com/agents-docs/qL8kqlSYbvexR8cg/images/entities-light.svg?fit=max\&auto=format\&n=qL8kqlSYbvexR8cg\&q=85\&s=e5d51454ebea529d7d9219a140ab7e1e) ## Businesses, People, and Accounts ### Platform Your **platform** is you! It's the top-level entity that integrates with the Grid API. The platform: * Has its own configuration (webhook endpoint, supported currencies, API tokens, etc.) * A platform can have many customers both business and individual * Manages multiple customers and their accounts * Can hold platform-owned internal accounts for settlement and liquidity management * Acts as the integration point between your application and the open Money Grid ### Customers **Customers** are your end users who send and receive payments through your platform. Each customer: * Can be an individual or business entity * Has a KYC/KYB status that determines their ability to transact. If you are a regulated financial institution, this will typically be `APPROVED` since you do the KYC/KYB yourself. * Is identified by both a system-generated ID and optionally your platform-specific customer ID * May have associated internal accounts and external accounts * May have a unique **UMA address** (e.g., `$john.doe@yourdomain.com`). If you don't assign an UMA address when creating a customer, they will be assigned a system-generated one. ### Internal Accounts **Internal accounts** are Grid-managed accounts that hold balances in specific currencies. They can belong to either: * **Platform internal accounts** - Owned by the platform for settlement, liquidity, and float management * **Customer internal accounts** - Associated with specific customers for holding funds Internal accounts: * Have balances in a single currency (USD, EUR, MXN, etc.) * Can be funded via bank transfers or crypto deposits using payment instructions * Are used as sources or destinations for transactions instantly 24/7/365 * Track available balance for sending payments or receiving funds > [!NOTE] > > **Global Accounts** are a special kind of internal account, powered by a self-custody embedded [Spark](https://spark.money) wallet that Grid provisions for your customers to hold a stablecoin balance. They behave like any other internal account on the way in, but every outbound transfer must be authorized by a customer signature. See [Global Accounts](/payouts-and-b2b/global-accounts/overview) for the full overview. ### External Accounts **External accounts** are traditional bank accounts, crypto wallets, or other payment instruments connected to customers for on-ramping or off-ramping funds. Each external account: * Are associated with a specific customer or the platform * Represents a real-world bank account (with routing number, account number, IBAN, etc.), wallet, or payment instrument * Has an associated beneficiary (individual or business) who receives payments from the customer or platform * Has a status indicating screening status (ACTIVE, PENDING, INACTIVE, etc.) * Can be used as a destination for quote-based transfers or same currency transfers like withdrawals * For pullable sources like debit cards or ACH pulls, an external account can be used as a source for transfers-in to fund internal accounts or to fund cross-border transfers via quotes. ## Entity Examples by Use Case Understanding how entities map to your specific use case helps clarify your integration architecture. Here are common examples: ### B2B Payouts Platform (e.g., Bill.com, Routable) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The payouts platform itself | Your company providing AP automation | | **Customer** | Businesses sending payments to vendors | Acme Corp (your client company) | | **External Account** | Vendors/suppliers receiving payments | Office supply vendor, freelance contractor | **Flow**: Acme Corp (customer) uses your platform to pay their vendor invoices → funds move from Acme's internal account → to vendor's external bank account ### Direct Rewards Platform (Platform-Funded Model) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The app paying rewards directly to users | Your cashback app | | **Customer** | (Not used in this model) | N/A | | **External Account** | End users' crypto wallets receiving rewards | Sarah's self-custody Bitcoin wallet | **Flow**: Your platform sends micro-payouts directly from platform internal accounts → to users' external crypto wallets at scale. Common for cashback apps where the platform earns affiliate commissions and shares them with users. ### White-Label Rewards Platform (Customer-Funded Model) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The rewards infrastructure provider | Your white-label rewards API | | **Customer** | Brands or merchants running reward campaigns | Nike, Starbucks | | **External Account** | End users' crypto wallets receiving rewards | Sarah's self-custody Bitcoin wallet | **Flow**: Nike (customer) funds their internal account → your platform sends rewards on their behalf → to users' external crypto wallets. Common for brand loyalty programs where merchants manage their own reward budgets. ### Remittance/P2P App (e.g., Wise, Remitly) | Entity Type | Who They Are | Example | | - | - | - | | **Platform** | The remittance service | Your money transfer app | | **Customer** | Both sender and recipient of funds | Maria (sender in US), Juan (recipient in Mexico) | | **External Account** | Bank accounts for funding/receiving | Maria's US bank (funding), Juan's Mexican bank (receiving funds) | **Flow**: Maria (customer) funds transfer from her external account → to Juan (also a customer) → who receives funds in his external bank account. Alternatively, Maria could send to Juan's UMA address directly. ## Transactions and Addressing Entities ### Quotes **Quotes** provide locked-in exchange rates and payment instructions for transfers. A quote: * Specifies a source (internal account, customer ID, or the platform itself) and destination (internal/external account or UMA address) * Locks an exchange rate for a short period (typically 1-5 minutes) or can be immediately executed with the `immediatelyExecute` flag * Calculates total fees and amounts for currency conversion * Provides payment instructions for funding the transfer if needed, or can be funded via an internal account balance. * Must be executed before it expires * Creates a transaction when executed ### Transactions **Transactions** represent completed or in-progress payment transfers. Each transaction: * Has a type (INCOMING or OUTGOING), keyed on whether the destination is an internal account * Has a status (PENDING, COMPLETED, FAILED, etc.) * References a customer (sender for outgoing, recipient for incoming) or a platform internal account * Specifies source and destination (accounts or UMA addresses) * Includes amounts, currencies, and settlement information * May include counterparty information for compliance purposes if required by your platform configuration Transactions are created when a quote is executed, incoming or outgoing. This covers same-currency and cross-currency transfers alike. ### UMA Addresses (optional) **UMA addresses** are human-readable payment identifiers that follow the format `$username@domain.com`. They: * Uniquely identify entities on the Grid network * Enable sending and receiving payments across different platforms without knowing the recipient's underlying account details or personal information * Support currency negotiation and cross-border transfers * Work similar to email addresses but for payments * Are an optional UX improvement for some use cases. Use of UMA addresses is not required in order to use the Grid API.