# Check ACH debit eligibility Source: https://docs.spritz.finance/api-reference/ach-debit/check-ach-debit-eligibility https://platform.spritz.finance/openapi.json post /v1/ach-debit/eligibility Once an email is eligible, it remains eligible. While ACH debit is temporarily halted or paused for new users, the answer is false, even for an email that was eligible before, and nothing is recorded. A false response may be transient, so re-check rather than caching it. Call this endpoint before showing a bank deposit option. Once the user exists, the capabilities returned by GET /v1/users/me remain the source of truth. # Create an API key Source: https://docs.spritz.finance/api-reference/api-keys/create-an-api-key https://platform.spritz.finance/openapi.json post /v1/api-keys/ Creates a new API key for the authenticated user. The raw key is returned once and cannot be retrieved again. # List API keys Source: https://docs.spritz.finance/api-reference/api-keys/list-api-keys https://platform.spritz.finance/openapi.json get /v1/api-keys/ Lists all API keys for the authenticated user. Only key prefixes are returned, never the full key or hash. # Revoke an API key Source: https://docs.spritz.finance/api-reference/api-keys/revoke-an-api-key https://platform.spritz.finance/openapi.json delete /v1/api-keys/{keyId} Revokes an API key. This action is irreversible. # Create an auto-ramp account Source: https://docs.spritz.finance/api-reference/auto-ramp-accounts/create-an-auto-ramp-account https://platform.spritz.finance/openapi.json post /v1/auto-ramp-accounts/ Creates an auto-ramp account for the authenticated user. The account is a virtual bank account that automatically converts fiat deposits to crypto. The address must be valid for the specified network, and the network/token combination must be supported for your region. **Idempotent per (`address`, `network`, `token`).** If the user already has an account for that combination, the request returns that account with `201` instead of creating another. Store the returned `id`; do not treat a `201` as proof that a new account was created. **Conflict** (`409`): branch on the problem `code`, not the title or detail. `AUTO_RAMP_ACCOUNT_CREATE_IN_PROGRESS` (`retryable: true`) means another request is creating an account for the same `address`, `network`, and `token`. Retry after a short delay to receive the same account. # Estimate the cost of a deposit Source: https://docs.spritz.finance/api-reference/auto-ramp-accounts/estimate-the-cost-of-a-deposit https://platform.spritz.finance/openapi.json get /v1/auto-ramp-accounts/{id}/estimate Returns what depositing `amount` into this account is expected to cost, before any money moves. **An estimate, not a quote.** No rate is locked. `rate.asOf` says when the rate was read; the figures may differ by the time funds arrive. **The fee is this account's.** It is a percentage held against the individual account, so two accounts can legitimately return different fees for the same amount. Do not cache an estimate against one account and reuse it for another. **`fees.estimated` says which parts can move.** A component listed there is an approximation; one not listed is exact as at `rate.asOf`. Network fees are never knowable before settlement — the chain charges what it charges at the moment of broadcast — so `network` is always listed, including where it rounds to `0.00` on networks that do not meaningfully charge it. `fees.maximum` and `output.minimum` bound the estimated components against recently settled deposits, and are what to show a user as "at most" and "at least". **The bound can be missing.** It is drawn from deposits we have actually settled on that network, so until enough have, `fees.maximum` and `output.minimum` are both omitted — and a `breakdown.network` of `0.00` alongside them means unmeasured, not free. `fees.networkFeeSamples` says how many settlements are behind the figures; at `0` we have never seen this network settle. Do not promise a user a ceiling on the cost, or a floor on what they receive, when those fields are absent. **Fees are in the account's own currency**, which is EUR on SEPA accounts. **When an estimate cannot be produced**, branch on `code`, not on the status alone — the codes match `GET /v1/on-ramps/exchange-rates`: - `rate_unavailable` (503) — the pair is quotable but has no rate right now. Transient; retry. - `unsupported_currency_pair` (400) — the pair is not quoted at all. Permanent. - `exchange_rate_provider_error` (502) — the rate provider failed otherwise. # Get an auto-ramp account Source: https://docs.spritz.finance/api-reference/auto-ramp-accounts/get-an-auto-ramp-account https://platform.spritz.finance/openapi.json get /v1/auto-ramp-accounts/{id} Returns a single auto-ramp account by ID for the authenticated user. # List auto-ramp accounts Source: https://docs.spritz.finance/api-reference/auto-ramp-accounts/list-auto-ramp-accounts https://platform.spritz.finance/openapi.json get /v1/auto-ramp-accounts/ Returns all auto-ramp accounts for the authenticated user. Each account is a virtual bank account that automatically converts fiat deposits to crypto. # List auto-ramp addresses Source: https://docs.spritz.finance/api-reference/auto-ramp-addresses/list-auto-ramp-addresses https://platform.spritz.finance/openapi.json get /v1/auto-ramp-addresses/ Returns the auto-ramp deposit addresses for the authenticated user, optionally filtered to a single account. Each address accepts stablecoin deposits on one network and automatically converts them into the linked account. # Add a bank account Source: https://docs.spritz.finance/api-reference/bank-accounts/add-a-bank-account https://platform.spritz.finance/openapi.json post /v1/bank-accounts/ Adds a new bank account as an off-ramp destination. The `type` field determines the required fields. ## Account Types | Type | Region | Required Fields | Supported Rails | |------|--------|-----------------|-----------------| | `us` | United States | `routingNumber`, `accountNumber` | ACH, RTP, Wire | | `ca` | Canada | `institutionNumber`, `transitNumber`, `accountNumber` | EFT | | `uk` | United Kingdom | `sortCode`, `accountNumber` | FPS | | `iban` | Europe / SEPA | `iban` | SEPA | For `iban` accounts `bic` is optional. The beneficiary's address is required. When `ownership` is `personal` it is taken from your verified identity; when `thirdParty` you must supply `accountHolder.address`, including its `country`. ## Examples **US Bank Account:** ```json { "type": "us", "ownership": "personal", "routingNumber": "021000021", "accountNumber": "123456789", "accountSubtype": "checking" } ``` **Canadian Bank Account:** ```json { "type": "ca", "ownership": "thirdParty", "accountHolder": { "firstName": "Jane", "lastName": "Smith" }, "institutionNumber": "001", "transitNumber": "12345", "accountNumber": "1234567" } ``` **IBAN (SEPA) Bank Account:** ```json { "type": "iban", "ownership": "personal", "iban": "DE89370400440532013000", "bic": "COBADEFFXXX" } ``` The account will be verified before becoming active. # Complete bank account linking Source: https://docs.spritz.finance/api-reference/bank-accounts/complete-bank-account-linking https://platform.spritz.finance/openapi.json post /v1/bank-accounts/link-complete Completes the Plaid Link flow by exchanging the public token, retrieving account details, and storing the linked bank accounts. # Create a bank account link token Source: https://docs.spritz.finance/api-reference/bank-accounts/create-a-bank-account-link-token https://platform.spritz.finance/openapi.json post /v1/bank-accounts/link-token Creates a Plaid Link token and hosted URL for linking a bank account. Use the linkToken with Plaid Link SDK, or open hostedLinkUrl in a browser/webview. # Get a bank account Source: https://docs.spritz.finance/api-reference/bank-accounts/get-a-bank-account https://platform.spritz.finance/openapi.json get /v1/bank-accounts/{accountId} Returns details for a specific bank account. # Get payment limits Source: https://docs.spritz.finance/api-reference/bank-accounts/get-payment-limits https://platform.spritz.finance/openapi.json get /v1/bank-accounts/{accountId}/payment-limits Returns payment limits for a specific bank account, including per-transaction and daily volume limits. # List bank accounts Source: https://docs.spritz.finance/api-reference/bank-accounts/list-bank-accounts https://platform.spritz.finance/openapi.json get /v1/bank-accounts/ Returns all bank accounts for the authenticated user. # Remove a bank account Source: https://docs.spritz.finance/api-reference/bank-accounts/remove-a-bank-account https://platform.spritz.finance/openapi.json delete /v1/bank-accounts/{accountId} Removes a bank account. Any pending off-ramps will still be processed. # Activate bills Source: https://docs.spritz.finance/api-reference/bills/activate-bills https://platform.spritz.finance/openapi.json post /v1/bills/activate Starts bills activation for the authenticated user and, when possible, completes the initial bill sync in the same request. # Complete a card update Source: https://docs.spritz.finance/api-reference/bills/complete-a-card-update https://platform.spritz.finance/openapi.json post /v1/bills/{billId}/card-update-complete Refreshes a bill after the user completes the card-update module, returning its updated payability and any remaining requirements. # Link card via token Source: https://docs.spritz.finance/api-reference/bills/link-card-via-token https://platform.spritz.finance/openapi.json post /v1/bills/token Link a card to your account using an encrypted token from the TabaPay Browser SDK. Optionally overwrites an existing bill. # List bills Source: https://docs.spritz.finance/api-reference/bills/list-bills https://platform.spritz.finance/openapi.json get /v1/bills/ Returns all linked bills for the authenticated user. # List removed bills Source: https://docs.spritz.finance/api-reference/bills/list-removed-bills https://platform.spritz.finance/openapi.json get /v1/bills/removed Returns bills the authenticated user removed that can be restored with `POST /v1/bills/{billId}/restore`, with status `deleted` and a `deletedAt`. Bills the provider deactivated, and closed accounts, are not included. # Remove a bill Source: https://docs.spritz.finance/api-reference/bills/remove-a-bill https://platform.spritz.finance/openapi.json delete /v1/bills/{billId} Removes a linked bill for the authenticated user. The bill is soft-deleted: it no longer appears in the user's bill list but is retained for audit. # Restore a removed bill Source: https://docs.spritz.finance/api-reference/bills/restore-a-removed-bill https://platform.spritz.finance/openapi.json post /v1/bills/{billId}/restore Brings back a bill the authenticated user previously removed. Returns 404 when the bill is not the user's and 409 when it is not removed or cannot be restored. # Start a card update Source: https://docs.spritz.finance/api-reference/bills/start-a-card-update https://platform.spritz.finance/openapi.json post /v1/bills/{billId}/card-update-token Issues a short-lived token that launches the card-update module for a bill in the action_required state, so the user can supply the missing card number. # Start bills verification Source: https://docs.spritz.finance/api-reference/bills/start-bills-verification https://platform.spritz.finance/openapi.json post /v1/bills/start_verification Starts the bills activation fallback verification step using the authenticated user's verified identity data. # Submit bills verification challenge Source: https://docs.spritz.finance/api-reference/bills/submit-bills-verification-challenge https://platform.spritz.finance/openapi.json post /v1/bills/submit_verification/{activationId} Submits challenge responses for an in-progress bills activation verification flow. # Update a bill's account number Source: https://docs.spritz.finance/api-reference/bills/update-a-bills-account-number https://platform.spritz.finance/openapi.json post /v1/bills/{billId}/account-number Submits an Evervault-encrypted account number for a bill in the action_required state with an account_details requirement, then returns its updated payability and any remaining requirements. # Get card Source: https://docs.spritz.finance/api-reference/cards/get-card https://platform.spritz.finance/openapi.json get /v1/cards/{cardId} Retrieves a single card by ID for the authenticated user. # Get card PIN Source: https://docs.spritz.finance/api-reference/cards/get-card-pin https://platform.spritz.finance/openapi.json post /v1/cards/{cardId}/pin Retrieves the encrypted card PIN. **Encryption:** The PIN is returned as an AES-128-GCM encrypted payload. Use the session's AES key to decrypt it. **PIN Block Format (ISO 9564-1 Format 2):** After decryption, the plaintext is a 16-character PIN block with the following structure: - Byte 0: Control field (`2` indicates Format 2) - Byte 1: PIN length in hexadecimal (4-12) - Bytes 2-(1+N): The actual PIN digits - Remaining bytes: Padding (`F`) **Example:** A 4-digit PIN "1234" would be encoded as: `241234FFFFFFFFFF` **Parsing the PIN block:** ``` const pinLength = parseInt(pinBlock[1], 16); const pin = pinBlock.slice(2, 2 + pinLength); ``` # Get card program balance Source: https://docs.spritz.finance/api-reference/cards/get-card-program-balance https://platform.spritz.finance/openapi.json get /v1/cards/balance Retrieves the card program balance for the authenticated user. # List card transactions Source: https://docs.spritz.finance/api-reference/cards/list-card-transactions https://platform.spritz.finance/openapi.json get /v1/cards/{cardId}/transactions Retrieves a paginated list of transactions for a specific card. # List cards Source: https://docs.spritz.finance/api-reference/cards/list-cards https://platform.spritz.finance/openapi.json get /v1/cards/ Retrieves all cards for the authenticated user. # Update card PIN Source: https://docs.spritz.finance/api-reference/cards/update-card-pin https://platform.spritz.finance/openapi.json post /v1/cards/{cardId}/update_pin Updates the card PIN. **PIN Block Format (ISO 9564-1 Format 2):** The PIN must be formatted as a 16-character PIN block before encryption: - Byte 0: Control field (`2` indicates Format 2) - Byte 1: PIN length in hexadecimal (4-12) - Bytes 2-(1+N): The actual PIN digits - Remaining bytes: Padding (`F`) **Example:** A 4-digit PIN "5678" would be encoded as: `245678FFFFFFFFFF` **Formatting the PIN block:** ``` const pinBlock = `2${pinLength.toString(16)}${pin}${'F'.repeat(14 - pinLength)}`; ``` **Encryption:** 1. Generate a random AES-128 key 2. Encrypt the PIN block using AES-128-GCM with the random key 3. RSA-encrypt the AES key using the server's public key 4. Send the RSA-encrypted key as `encryptedKey` and the AES-encrypted PIN block as `encryptedPin` **Validation:** - PIN must be 4-12 digits - Weak PINs (repeating like 1111, sequential like 1234) are rejected # Update card spend limit Source: https://docs.spritz.finance/api-reference/cards/update-card-spend-limit https://platform.spritz.finance/openapi.json post /v1/cards/{cardId}/update_limit Updates the card spend limit with a specified amount and interval (daily, weekly, monthly, yearly, all_time, per_transaction). # Update card status Source: https://docs.spritz.finance/api-reference/cards/update-card-status https://platform.spritz.finance/openapi.json post /v1/cards/{cardId}/update_status Updates the card status. Valid statuses are: active, locked, canceled. # Get regional compliance requirements Source: https://docs.spritz.finance/api-reference/compliance/get-regional-compliance-requirements https://platform.spritz.finance/openapi.json get /v1/users/me/compliance/requirements Returns the additional compliance fields the authenticated user's region requires, which of them have been collected, and the deadline for supplying the rest. Users outside a regulated region get `required: false` with an empty `fields` array. While `complete` is false, capabilities for the region are gated behind a `regional_compliance` requirement. # Submit regional compliance fields Source: https://docs.spritz.finance/api-reference/compliance/submit-regional-compliance-fields https://platform.spritz.finance/openapi.json post /v1/users/me/compliance Submits the additional compliance fields required for the authenticated user's region. All required fields must be supplied together — partial submissions are rejected with field-level errors rather than being stored. If the user has accepted the provider's terms, submitting these fields creates or updates the provider customer immediately and `bridgeCustomerUpdated` is true. Otherwise, the fields are stored and included when the customer is created. # Add a debit card Source: https://docs.spritz.finance/api-reference/debit-cards/add-a-debit-card https://platform.spritz.finance/openapi.json post /v1/debit-cards/ Adds a debit card as a push-to-card payout destination. Never send a raw card number: collect it with the Evervault Card component in the user's browser (initialised with the Evervault team ID and per-environment app ID that Spritz provides during onboarding) and forward the encrypted `card.number` token together with the plaintext expiry, last four, BIN and brand it returns. Requires an active `crypto_to_fiat` / `push_to_card` capability. See the Push to debit card guide. # Complete debit card details Source: https://docs.spritz.finance/api-reference/debit-cards/complete-debit-card-details https://platform.spritz.finance/openapi.json patch /v1/debit-cards/{cardId}/cardholder-info Supplies the cardholder name and billing address for an existing debit card that is missing them (recapture). Clears the card's action_required requirement. # Delete a debit card Source: https://docs.spritz.finance/api-reference/debit-cards/delete-a-debit-card https://platform.spritz.finance/openapi.json delete /v1/debit-cards/{cardId} Removes a debit card. The underlying payable account is soft-deleted. # Get a debit card Source: https://docs.spritz.finance/api-reference/debit-cards/get-a-debit-card https://platform.spritz.finance/openapi.json get /v1/debit-cards/{cardId} Returns details for a specific debit card, including tokenization status. # List debit cards Source: https://docs.spritz.finance/api-reference/debit-cards/list-debit-cards https://platform.spritz.finance/openapi.json get /v1/debit-cards/ Returns all debit cards for the authenticated user, including tokenization status. # Create a direct deposit Source: https://docs.spritz.finance/api-reference/deposits/create-a-direct-deposit https://platform.spritz.finance/openapi.json post /v1/deposits/direct Authorizes and creates a deposit against a raw wallet address prepared via /direct/prepare. Integrators should send the preparation's short-lived submissionToken to the authorizing client and call this endpoint directly from that client; the capability can submit only its bound preparation. During migration, integrators can continue submitting with HMAC backend authentication but must include the public clientIp observed by their edge. Authorization is derived from the verified ACH funding source; no wallet signature is required. Idempotency-Key is required; reuse the same key and body after a timeout to recover the original response without creating another deposit. Availability errors: 403 with code `ach_debit_temporarily_unavailable` means ACH debit is temporarily paused for this user (rollout pause) — offer another deposit method and do not retry. 409 with code `ach_debit_program_halted` means ACH debit is temporarily halted for this program (master stop, book halt, or integrator halt); 409 with code `ach_debit_new_user_paused` means new users temporarily cannot start ACH debit deposits. Both 409s are temporary: offer another method and retry later. The funding source's deposit-limits response reports the same stops as `rail_halted` / `new_user_admission_paused`, and the capability reports `not_available` while they last. # Get a deposit Source: https://docs.spritz.finance/api-reference/deposits/get-a-deposit https://platform.spritz.finance/openapi.json get /v1/deposits/{depositId} Returns the latest ACH debit and crypto release state for a deposit owned by the authenticated user. Readable whenever the integrator has ACH debit enabled, including while ACH debit is paused, halted, or not offered to this user. # List deposits Source: https://docs.spritz.finance/api-reference/deposits/list-deposits https://platform.spritz.finance/openapi.json get /v1/deposits/ Returns the authenticated user's ACH debit deposits newest first. Use nextCursor to retrieve subsequent pages and reconcile asynchronous debit, release, settlement, return, and failure states. Readable whenever the integrator has ACH debit enabled, including while ACH debit is paused, halted, or not offered to this user. # Prepare a direct deposit authorization Source: https://docs.spritz.finance/api-reference/deposits/prepare-a-direct-deposit-authorization https://platform.spritz.finance/openapi.json post /v1/deposits/direct/prepare Creates the canonical ACH authorization message for a deposit targeting a raw wallet address. Integrators can include the public client IP their edge observed to receive a short-lived submissionToken for direct client submission. During migration, an integrator can omit clientNetwork and submit through its HMAC-authenticated backend by including clientIp on create. Authorization is derived from the verified ACH funding source; no wallet signature is required. Availability errors: 403 with code `ach_debit_temporarily_unavailable` means ACH debit is temporarily paused for this user (rollout pause) — offer another deposit method and do not retry. 409 with code `ach_debit_program_halted` means ACH debit is temporarily halted for this program (master stop, book halt, or integrator halt); 409 with code `ach_debit_new_user_paused` means new users temporarily cannot start ACH debit deposits. Both 409s are temporary: offer another method and retry later. The funding source's deposit-limits response reports the same stops as `rail_halted` / `new_user_admission_paused`, and the capability reports `not_available` while they last. # Approve device authorization Source: https://docs.spritz.finance/api-reference/device-authorization/approve-device-authorization https://platform.spritz.finance/openapi.json post /v1/device/approve User approves the CLI's device authorization request, selecting permissions and expiry. Mints a scoped API key. # Get device authorization request info Source: https://docs.spritz.finance/api-reference/device-authorization/get-device-authorization-request-info https://platform.spritz.finance/openapi.json get /v1/device/info Returns details about a pending device authorization request so the user can review before approving. # Initiate device authorization Source: https://docs.spritz.finance/api-reference/device-authorization/initiate-device-authorization https://platform.spritz.finance/openapi.json post /v1/device/authorize Generates a device code and user code for CLI authentication. The user opens verificationUriComplete in their browser to approve. # Poll for device authorization token Source: https://docs.spritz.finance/api-reference/device-authorization/poll-for-device-authorization-token https://platform.spritz.finance/openapi.json post /v1/device/token CLI polls this endpoint to check if the user has approved the device code. Returns the API key on success. # Get a funding source Source: https://docs.spritz.finance/api-reference/funding-sources/get-a-funding-source https://platform.spritz.finance/openapi.json get /v1/funding-sources/{fundingSourceId} Returns details for a specific funding source for the authenticated user. # Get deposit limits for a funding source Source: https://docs.spritz.finance/api-reference/funding-sources/get-deposit-limits-for-a-funding-source https://platform.spritz.finance/openapi.json get /v1/funding-sources/{fundingSourceId}/deposit-limits Returns the authenticated user's current ACH debit limits and remaining capacity for this funding source. # List funding sources Source: https://docs.spritz.finance/api-reference/funding-sources/list-funding-sources https://platform.spritz.finance/openapi.json get /v1/funding-sources/ Returns all funding sources for the authenticated user. # Remove a funding source Source: https://docs.spritz.finance/api-reference/funding-sources/remove-a-funding-source https://platform.spritz.finance/openapi.json delete /v1/funding-sources/{fundingSourceId} Removes a funding source and releases the bank connection behind it, so the same bank account can be linked again. The underlying bank account is not deleted. An account disabled after a returned payment cannot be linked again once removed. # Approve connect session Source: https://docs.spritz.finance/api-reference/integrator-connect/approve-connect-session https://platform.spritz.finance/openapi.json post /v1/connect/sessions/{sessionId}/approve Approves the Spritz Connect session and returns a callback URI containing the authorization code. When the session was created with an email, the signed-in account must have that email; otherwise returns 403 CONNECT_ACCOUNT_MISMATCH and the session stays pending. # Create a connect session Source: https://docs.spritz.finance/api-reference/integrator-connect/create-a-connect-session https://platform.spritz.finance/openapi.json post /v1/integrator/connect/sessions Creates a Spritz Connect session for an existing Spritz user to authorize the integrator. # Exchange connect code for API key Source: https://docs.spritz.finance/api-reference/integrator-connect/exchange-connect-code-for-api-key https://platform.spritz.finance/openapi.json post /v1/integrator/connect/token Exchanges an authorization code for the long-lived users-service API key minted for this integrator/user grant. # Get connect session Source: https://docs.spritz.finance/api-reference/integrator-connect/get-connect-session https://platform.spritz.finance/openapi.json get /v1/connect/sessions/{sessionId} Returns the pending Spritz Connect session for the signed-in user to review. # Preview a connect session before sign-in Source: https://docs.spritz.finance/api-reference/integrator-connect/preview-a-connect-session-before-sign-in https://platform.spritz.finance/openapi.json post /v1/connect/sessions/{sessionId}/preview Returns who is asking and which account the session is for, so the hosted page can render before sign-in. Requires the scoped returnToken from the authorization URL fragment; a session ID alone grants no authority. Read-only. Invalid or expired tokens return 404. # Return from an unfinished connect session Source: https://docs.spritz.finance/api-reference/integrator-connect/return-from-an-unfinished-connect-session https://platform.spritz.finance/openapi.json post /v1/connect/sessions/{sessionId}/return Ends a pending session without granting access. Requires the scoped returnToken delivered in the authorization URL fragment; a session ID alone grants no authority. Sign-in is not required. The token expires 15 minutes after creation, including a five-minute grace period after consent expires. Invalid or expired tokens return 404. Returns only the registered callback with its original state and a server-selected error. Retries preserve the first terminal outcome. Approved sessions return 409. # Create a user Source: https://docs.spritz.finance/api-reference/integrator/create-a-user https://platform.spritz.finance/openapi.json post /v1/integrator/users Creates a new Spritz user under the authenticated integrator. The response includes the user's API key (`ak_...`), which is returned only once at creation - store it securely. **Conflict** (`409`): branch on the problem `code`, not the title or detail. `USER_ALREADY_EXISTS` means the email already has a Spritz account: use Spritz Connect (`POST /v1/integrator/connect/sessions`) with the same `email` to have the existing user authorize this integrator; the hosted page shows that email and only that account can approve. `USER_CREATE_IN_PROGRESS` (`retryable: true`) means another request is already creating a user with this email; retry shortly. # Create webhook Source: https://docs.spritz.finance/api-reference/integrator/create-webhook https://platform.spritz.finance/openapi.json post /v1/integrator/webhooks Creates a new webhook for the integrator. # Delete webhook Source: https://docs.spritz.finance/api-reference/integrator/delete-webhook https://platform.spritz.finance/openapi.json delete /v1/integrator/webhooks/{webhookId} Deletes a webhook by ID. # Exchange credentials for JWT token Source: https://docs.spritz.finance/api-reference/integrator/exchange-credentials-for-jwt-token https://platform.spritz.finance/openapi.json post /v1/integrator/tokens Exchange HMAC-signed request for a short-lived JWT token. **Authentication**: Requires HMAC signature authentication with integrator credentials. **Use Case**: Backend integrators can exchange their HMAC credentials for a JWT token that can be used from frontend applications. **Token Lifetime**: Maximum 3600 seconds (60 minutes), default 3600 seconds. **Rate Limiting**: 100 token exchanges per minute per integrator. # Get an ACH debit return Source: https://docs.spritz.finance/api-reference/integrator/get-an-ach-debit-return https://platform.spritz.finance/openapi.json get /v1/integrator/ach-debit/returns/{returnId} Returns a single ACH debit return scoped to the authenticated integrator. # Get integrator profile Source: https://docs.spritz.finance/api-reference/integrator/get-integrator-profile https://platform.spritz.finance/openapi.json get /v1/integrator/ Returns the authenticated integrator's profile information. # Get webhook deliveries Source: https://docs.spritz.finance/api-reference/integrator/get-webhook-deliveries https://platform.spritz.finance/openapi.json get /v1/integrator/webhooks/deliveries Returns recent webhook delivery attempts for the integrator, newest first. Use this to tell **"Spritz never sent it"** apart from **"my endpoint rejected it"** — from the outside both look like silence. Read `error` first — it is what separates the two cases. - `success: true` — your endpoint accepted the delivery, and `responseStatus` is what it returned. - `success: false`, **no** `error`, **with** a `responseStatus` — the request reached your endpoint and it responded with that status. Your handler is being called and is failing. - `success: false` with **neither** `error` nor `responseStatus` — the outcome was not recorded. Only older records look like this; every delivery the current sender writes carries a status. There is nothing to diagnose from them. - `success: false` **with** an `error` — no usable response came back: a timeout, a refused connection, a DNS or certificate problem. `error` carries the underlying reason (for example `getaddrinfo ENOTFOUND ...`). Note that `responseStatus` is still present here — 504 for a timeout, 500 otherwise — but it is our classification of the failure, **not** something your endpoint said. Do not read it as your handler's response. > **An `error` does not prove the event was not processed.** A refused connection means it never arrived, but a timeout or a dropped connection may mean your endpoint received and fully handled the event and we simply never heard the answer. Treat these as *unknown*, not as *not delivered* — re-running non-idempotent work on the strength of an `error` is how you double-process. This is the case idempotent handlers exist for. `payload` is the exact body that was sent, and the body the `Signature` header was computed over, so it can be replayed against your own verification code. Cursor-paginated: pass the previous response's `nextCursor` as `cursor` to walk further back. `nextCursor` is `null` on the last page. No fixed retention period is guaranteed. Treat this endpoint as a diagnostic and prompt-recovery aid, not permanent event storage or a long-lived redelivery queue. Store accepted webhook events durably and reconcile failed deliveries promptly. Deliveries are recorded for every webhook on the integrator. Scope is your own integrator only. # Get webhooks Source: https://docs.spritz.finance/api-reference/integrator/get-webhooks https://platform.spritz.finance/openapi.json get /v1/integrator/webhooks Returns all webhooks configured for the integrator. # List ACH debit returns Source: https://docs.spritz.finance/api-reference/integrator/list-ach-debit-returns https://platform.spritz.finance/openapi.json get /v1/integrator/ach-debit/returns Returns ACH debit returns scoped to the authenticated integrator. # Regenerate API secret Source: https://docs.spritz.finance/api-reference/integrator/regenerate-api-secret https://platform.spritz.finance/openapi.json post /v1/integrator/regenerate-secret Regenerates the integrator's API secret. The new secret is returned in the response - this is the only time it will be exposed. **Grace Period**: Optionally specify `oldSecretValidForSeconds` (0-3600) to keep the old secret valid during rotation. This prevents service disruption while updating your systems. **Warning**: Without a grace period, the old secret is invalidated immediately. Any requests signed with the old secret will fail. Store the new secret securely - it cannot be retrieved again. # Update webhook Source: https://docs.spritz.finance/api-reference/integrator/update-webhook https://platform.spritz.finance/openapi.json patch /v1/integrator/webhooks/{webhookId} Updates the event subscriptions for an existing webhook. Use `*` to subscribe to all events. # Update webhook secret Source: https://docs.spritz.finance/api-reference/integrator/update-webhook-secret https://platform.spritz.finance/openapi.json post /v1/integrator/webhook-secret Updates the webhook secret used for signing webhook payloads. **Rotation**: The previous secret stays valid for signing for 24 hours after you set a new one; `previousSecretValidUntil` says when it stops. During that window each delivery carries one `v1=` signature per active secret, current secret first. Accept a delivery if any signature matches a secret you hold. `previousSecretValidUntil` is `null` when no different secret was replaced. **`Spritz-Signature` header**: `Spritz-Signature: t=,v1=.")>[,v1=...]`. To verify, for each secret you hold recompute HMAC-SHA256 over the `t` value, a `.`, and the exact raw request body, then compare it to each `v1` value with a constant-time comparison. Reject the delivery if no signature matches or if `t` is more than 300 seconds from your current time. **Legacy `Signature` header**: Unchanged and still sent: the hex HMAC-SHA256 of the raw request body using the current secret only. It has no timestamp, so prefer `Spritz-Signature`. If no webhook secret is set, webhook requests include neither header. **Conflict** (`409`): `code: "WEBHOOK_SECRET_UPDATE_IN_PROGRESS"` (`retryable: true`) means another request changed the secret at the same time; retry the request. # Create an off-ramp quote Source: https://docs.spritz.finance/api-reference/off-ramp-quotes/create-an-off-ramp-quote https://platform.spritz.finance/openapi.json post /v1/off-ramp-quotes/ Creates a quote to convert crypto to fiat. Check `fulfillment` to determine the next step: - `sign_transaction`: Call `POST /off-ramp-quotes/{id}/transaction` for calldata, sign, and submit on-chain. - `send_to_address`: Send crypto to `sendTo.address` before `sendTo.expiresAt`. **Amount modes:** `output` (default) fixes the destination fiat amount and remains supported for USD destinations. EUR destinations require `input`: `amount` is the exact USD value Spritz collects, while quote output is an estimate. Actual settlement is reported by the off-ramp resource. # Get an off-ramp quote Source: https://docs.spritz.finance/api-reference/off-ramp-quotes/get-an-off-ramp-quote https://platform.spritz.finance/openapi.json get /v1/off-ramp-quotes/{quoteId} Returns details for a specific off-ramp quote. # Get transaction params for a quote Source: https://docs.spritz.finance/api-reference/off-ramp-quotes/get-transaction-params-for-a-quote https://platform.spritz.finance/openapi.json post /v1/off-ramp-quotes/{quoteId}/transaction Returns transaction parameters for a `sign_transaction` quote. Use these to construct, sign, and submit the on-chain transaction. Returns EVM calldata, or a serialized Solana or Sui transaction, depending on the quote's chain. Solana and Sui transactions are built for `senderAddress`, so pass it. # Report the transaction sent for a quote Source: https://docs.spritz.finance/api-reference/off-ramp-quotes/report-the-transaction-sent-for-a-quote https://platform.spritz.finance/openapi.json post /v1/off-ramp-quotes/{quoteId}/submit Tells Spritz which on-chain transaction you broadcast for this quote, so tracking starts immediately instead of when the chain watcher notices it. Works for both fulfillment types: the transaction you signed from `POST /off-ramp-quotes/{id}/transaction`, or the transfer you sent to `sendTo.address`. The quote moves to `transaction_pending` and its off-ramp (fiat leg) is created right away; `confirmed` still comes from the chain. Idempotent for the same hash. Reporting a different hash once one is on record is rejected with `400` — the chain watcher is the authority from then on. # Get an off-ramp Source: https://docs.spritz.finance/api-reference/off-ramps/get-an-off-ramp https://platform.spritz.finance/openapi.json get /v1/off-ramps/{offRampId} Returns details for a specific off-ramp payment. # List off-ramps Source: https://docs.spritz.finance/api-reference/off-ramps/list-off-ramps https://platform.spritz.finance/openapi.json get /v1/off-ramps/ Returns a paginated list of off-ramp payments for the authenticated user. **Filtering:** - `status`: Filter by payment status - `network`: Filter by payment network - `accountId`: Filter by destination account ID **Sorting:** - `sort=desc` (default): Newest first - `sort=asc`: Oldest first **Pagination:** Use cursor-based pagination with the `cursor` parameter. # Refund an off-ramp Source: https://docs.spritz.finance/api-reference/off-ramps/refund-an-off-ramp https://platform.spritz.finance/openapi.json post /v1/off-ramps/{offRampId}/refund Refunds a **failed** off-ramp payment. Choose how the funds are returned: - `credit`: Return the funds to the user's Spritz balance. - `account`: Reissue the payout to a bank account — pass `accountId`, or omit it to reuse the original destination account. Only failed Modern Treasury and Checkbook off-ramps can be refunded. **Recommended:** send an `Idempotency-Key` header (a unique key per refund, reused verbatim on retries) so a timed-out request that is retried replays the original response instead of returning a stale "not refundable" error. # Get an on-ramp Source: https://docs.spritz.finance/api-reference/on-ramps/get-an-on-ramp https://platform.spritz.finance/openapi.json get /v1/on-ramps/{onRampId} Returns details for a specific on-ramp transaction. # Get exchange rates Source: https://docs.spritz.finance/api-reference/on-ramps/get-exchange-rates https://platform.spritz.finance/openapi.json get /v1/on-ramps/exchange-rates Returns the current on-ramp exchange rate from fiat to crypto. Rates are updated approximately every 30 seconds. This endpoint provides an estimate only — rates are not locked in and may differ at transaction time. Which tokens are quotable depends on the source currency, and matches `GET /v1/on-ramps/supported-pairs`: - **EUR** → EURC, USDB, USDC, USDG, USDP - **USD** → BTC, DAI, ETH, EURC, PYUSD, USDB, USDC, USDG, USDP, USDT **When a rate cannot be returned**, branch on `code`, not on the status alone: - `rate_unavailable` (503) — the pair is quotable but has no rate right now. Transient; retry. - `unsupported_currency_pair` (400) — the pair is not quoted at all. Permanent; retrying will not help. - `exchange_rate_provider_error` (502) — the rate provider failed for another reason. # List on-ramps Source: https://docs.spritz.finance/api-reference/on-ramps/list-on-ramps https://platform.spritz.finance/openapi.json get /v1/on-ramps/ Returns a paginated list of on-ramps for the authenticated user. On-ramps represent fiat-to-crypto conversion transactions where fiat was sent and crypto was or will be received at a destination address. **Filtering:** - `network`: Filter by blockchain network (ethereum, polygon, etc.) - `token`: Filter by output token (case-insensitive, e.g., "usdc") - `address`: Filter by payout address **Sorting:** - `sort=desc` (default): Newest first - `sort=asc`: Oldest first **Pagination:** Use cursor-based pagination with the `cursor` parameter. # List supported on-ramp pairs Source: https://docs.spritz.finance/api-reference/on-ramps/list-supported-on-ramp-pairs https://platform.spritz.finance/openapi.json get /v1/on-ramps/supported-pairs Returns supported rail/network/token pairs along with the minimum transaction amount in the source currency. # Activate bills with a sandbox simulation Source: https://docs.spritz.finance/api-reference/sandbox/activate-bills-with-a-sandbox-simulation https://platform.spritz.finance/openapi.json post /v1/sandbox/bills/activate Starts the bills activation flow in sandbox with a server-side Spinwheel simulation profile. # Bypass KYC (sandbox only) Source: https://docs.spritz.finance/api-reference/sandbox/bypass-kyc-sandbox-only https://platform.spritz.finance/openapi.json post /v1/sandbox/bypass-kyc Simulate KYC verification for testing purposes. **Only available in sandbox environments** — returns 403 in production. To simulate a successful verification, pass a `country` — the capability group to verify the user into. To simulate a failed check, pass `failed: true`. Exactly one of the two is required; an empty body is a 400. Add `retryable: true` with `failed: true` to simulate a retryable documentary failure, then POST /v1/users/me/verification-sessions to obtain a new Persona sandbox session. `US` and `EU` can be simulated today. `CA` and `GB` are real capability groups with no sandbox fixture yet and return **501**, which is distinct from the **400** an unrecognised value gets. # Create a sandbox direct deposit with a simulated outcome Source: https://docs.spritz.finance/api-reference/sandbox/create-a-sandbox-direct-deposit-with-a-simulated-outcome https://platform.spritz.finance/openapi.json post /v1/sandbox/deposits/direct Runs direct deposit creation through one deterministic sandbox simulation. Provide exactly one of `returnSimulation`, `riskSimulation`, or `lifecycleSimulation`. `Idempotency-Key` is required and must be reused with the same body after a timeout. An ACH return simulation creates the deposit normally and applies the selected return code to its sandbox debit. A blocking risk simulation selects a public decision outcome while keeping private provider inputs and policy rules out of the API. It returns **409** and creates no deposit: - `review_required` → `risk_review_required`; the source remains active; - `rejected` → `risk_rejected`; the source remains active; - `source_temporarily_unavailable` → `risk_rerouted`; the source becomes temporarily ineligible with `statusReason: rerouted` and an `availableAt` time; - `decision_unavailable` → `risk_evaluation_unavailable`; the source remains active. High-priority simulations create a deposit successfully: - `high_priority_available` → the deposit succeeds with its quoted instant portion. - `high_priority_downgraded` → the deposit succeeds at standard timing with no instant portion. Risk simulations force a fresh simulated decision but do not expose or call the production decision provider. Prepare a new authorization before retrying because the preparation is consumed by the create attempt. Lifecycle simulations never call a money-movement provider. `refunded`, `release_failed`, `release_stalled`, and `full_delivery` require a normal-priority preparation and apply the selected state before execution starts. `full_delivery` records a completed deposit and `achDebit.delivered` milestone. `partial_then_full_delivery` requires a high-priority preparation with both instant and settlement portions; it records deterministic `achDebit.deliveryProgress` and `achDebit.delivered` milestones for the same deposit, then returns the completed deposit. # Link a deterministic sandbox bank account Source: https://docs.spritz.finance/api-reference/sandbox/link-a-deterministic-sandbox-bank-account https://platform.spritz.finance/openapi.json post /v1/sandbox/bank-accounts/link Creates a Plaid custom Sandbox item without opening Link, then runs the normal Spritz token exchange, ownership evaluation, bank-account sync, funding-source creation, cache invalidation, and event paths. Query the returned account's fundingSourceId for the resulting ownership eligibility state. # Prepare a sandbox deposit under a program-control state Source: https://docs.spritz.finance/api-reference/sandbox/prepare-a-sandbox-deposit-under-a-program-control-state https://platform.spritz.finance/openapi.json post /v1/sandbox/deposits/direct/prepare Evaluates a deterministic sandbox-only program halt or new-user pause through the normal ACH debit policy gate. Blocking simulations return the same 409 problem response an integrator must handle in production and do not create a preparation. # Read ACH debit sandbox exposure Source: https://docs.spritz.finance/api-reference/sandbox/read-ach-debit-sandbox-exposure https://platform.spritz.finance/openapi.json get /v1/sandbox/ach-debit/exposure Returns the authenticated integrator's sandbox-only W1/W2 exposure and configured caps. Internal risk thresholds, reserve calculations, other integrators, and the global Spritz book are never exposed. # Remove a funding source (sandbox only) Source: https://docs.spritz.finance/api-reference/sandbox/remove-a-funding-source-sandbox-only https://platform.spritz.finance/openapi.json delete /v1/sandbox/funding-sources/{fundingSourceId} Permanently removes an ACH debit funding source for the authenticated user. **Only available in sandbox environments** — returns 403 in production. Intended for resetting funding sources during integration testing. # Reset sandbox bills activation Source: https://docs.spritz.finance/api-reference/sandbox/reset-sandbox-bills-activation https://platform.spritz.finance/openapi.json post /v1/sandbox/bills/reset Deletes the current user's Spinwheel sandbox user and clears local bills activation state so the flow can be run again from scratch. # Set ACH debit sandbox exposure caps Source: https://docs.spritz.finance/api-reference/sandbox/set-ach-debit-sandbox-exposure-caps https://platform.spritz.finance/openapi.json post /v1/sandbox/ach-debit/exposure/cap Sets the authenticated integrator's sandbox-only W1 and W2 caps. Use the read endpoint first and restore both original values after the scenario. This control cannot address the global Spritz book or another integrator. # Simulate a deposit into an auto-ramp account (sandbox only) Source: https://docs.spritz.finance/api-reference/sandbox/simulate-a-deposit-into-an-auto-ramp-account-sandbox-only https://platform.spritz.finance/openapi.json post /v1/sandbox/auto-ramp-accounts/{id}/deposit Simulates a fiat deposit arriving in this auto-ramp account and settling to crypto. **Only available in sandbox environments** — returns 403 in production. There is no other way to make an auto-ramp settle in sandbox: the provider's own sandbox cannot credit a virtual account. This drives the same ingestion path a real deposit takes, so the on-ramp it produces is an ordinary on-ramp — it appears in `GET /v1/on-ramps`, fires the same `onramp.*` webhooks, and carries the same fee breakdown. The fee is the account's own percentage and the conversion is priced off the live rate, so the figures should agree with `GET /v1/auto-ramp-accounts/{id}/estimate` for the same amount, up to the network fee — which no estimate can know in advance and which you set here yourself. Poll `onRampId` at `GET /v1/on-ramps/{id}`. # Start sandbox bills verification Source: https://docs.spritz.finance/api-reference/sandbox/start-sandbox-bills-verification https://platform.spritz.finance/openapi.json post /v1/sandbox/bills/start_verification Continues a sandbox bills activation flow into the fallback verification step. # Submit sandbox bills verification challenge Source: https://docs.spritz.finance/api-reference/sandbox/submit-sandbox-bills-verification-challenge https://platform.spritz.finance/openapi.json post /v1/sandbox/bills/submit_verification/{activationId} Submits challenge responses for a sandbox bills activation verification flow. # Accept terms of service Source: https://docs.spritz.finance/api-reference/terms/accept-terms-of-service https://platform.spritz.finance/openapi.json post /v1/users/me/terms Records acceptance of the terms a capability requires, by submitting the signed agreement id the user obtained from the provider's hosted flow. The `agreementId` is opaque — the platform resolves which provider it belongs to from the user's outstanding `terms_acceptance` requirement. Callers never need to know the provider. While the requirement is outstanding, the user's capabilities carry a `terms_acceptance` requirement whose `actionUrl` is the hosted flow that produces this id. # Confirm an email update Source: https://docs.spritz.finance/api-reference/users/confirm-an-email-update https://platform.spritz.finance/openapi.json post /v1/users/me/email-update-sessions/{sessionId}/confirm Confirms a pending email update session with the code sent to the new email address and returns the updated user profile. # Create, resume, or retry an identity verification session Source: https://docs.spritz.finance/api-reference/users/create-resume-or-retry-an-identity-verification-session https://platform.spritz.finance/openapi.json post /v1/users/me/verification-sessions/ Creates or resumes the authenticated user's Persona identity verification session without requesting an implicit enhanced upgrade, or starts a new inquiry when a failed verification is retryable. Check GET /v1/users/me: verification.status=retry and identity_verification requirements with retryable=true indicate eligibility. Overlapping REST session requests for the same user return 409 Conflict while a request is in progress. Retry the same POST after the first request completes. Temporary provider unavailability returns 503 VERIFICATION_TEMPORARILY_UNAVAILABLE with retryAfter (seconds). URLs and tokens may expire and are retrieved just in time. Returns 409 VERIFICATION_NOT_RETRYABLE for a permanent failure, VERIFICATION_UNDER_REVIEW while review is pending, VERIFICATION_ALREADY_VERIFIED when verified with no pending verification, or VERIFICATION_SESSION_UNAVAILABLE when unavailable or an interrupted retry requires support reconciliation. An interrupted retry with a saved Persona idempotency key or inquiry ID resumes that inquiry and completes pending event delivery on a subsequent POST; creation that is still ambiguous after 23 hours returns VERIFICATION_SESSION_UNAVAILABLE instead of starting another inquiry. An existing pending step-up remains resumable; this call does not initiate a new step-up for an already verified user. # Get the current authenticated user Source: https://docs.spritz.finance/api-reference/users/get-the-current-authenticated-user https://platform.spritz.finance/openapi.json get /v1/users/me Returns the authenticated user's profile information. # Start an email update Source: https://docs.spritz.finance/api-reference/users/start-an-email-update https://platform.spritz.finance/openapi.json post /v1/users/me/email-update-sessions/ Creates a pending email update session for the authenticated user and sends a one-time code to the requested email address. # Update the current authenticated user Source: https://docs.spritz.finance/api-reference/users/update-the-current-authenticated-user https://platform.spritz.finance/openapi.json post /v1/users/me Updates the authenticated user's profile. Both fields are optional - include only what you want to change. **Updatable fields:** - `timezone`: User's timezone in IANA format (e.g., "America/New_York") - `notificationPreferences`: Array of notification preference updates **Partial updates for notification preferences:** The `notificationPreferences` array supports partial updates at two levels: 1. **Type-level**: Only include notification types you want to modify. Types not included will keep their existing settings. 2. **Field-level**: For each type included, you must provide both `email` and `mobile` fields, but only the values you provide will be applied. **Example - Update just one notification type:** ```json { "notificationPreferences": [ { "type": "cardTransaction.approved", "email": false, "mobile": true } ] } ``` This updates only "cardTransaction.approved" preferences; all other notification types remain unchanged. **Example - Update only timezone:** ```json { "timezone": "Europe/London" } ``` # Commit add auth method Source: https://docs.spritz.finance/api-reference/wallet-kit/commit-add-auth-method https://platform.spritz.finance/openapi.json post /v1/wallet-kit/auth-methods/ Submits the user's stamp for a previously prepared add-auth-method intent. Returns the newly registered auth method. # Commit email OTP start Source: https://docs.spritz.finance/api-reference/wallet-kit/commit-email-otp-start https://platform.spritz.finance/openapi.json post /v1/wallet-kit/auth-methods/email/start Submits the user's stamp for a previously prepared email-start intent. Dispatches the OTP and returns the `otpId` to pass back to the add-auth-method prepare call. # Commit remove auth method Source: https://docs.spritz.finance/api-reference/wallet-kit/commit-remove-auth-method https://platform.spritz.finance/openapi.json delete /v1/wallet-kit/auth-methods/{id} Submits the user's stamp for a previously prepared remove-auth-method intent. Returns 204 on success. Returns 409 if the method is the user's last (`LAST_AUTH_METHOD`). # Get embedded wallet status Source: https://docs.spritz.finance/api-reference/wallet-kit/get-embedded-wallet-status https://platform.spritz.finance/openapi.json get /v1/wallet-kit/ Returns the authenticated user's embedded wallet status. Always 200: branch on `status` to handle the not-yet-provisioned case (`status === "not_provisioned"`) versus an active wallet (`status === "active"`, with `data` populated). # Get Kamino yield position Source: https://docs.spritz.finance/api-reference/wallet-kit/get-kamino-yield-position https://platform.spritz.finance/openapi.json get /v1/wallet-kit/yield/solana/kamino/positions/{vaultId} `data` is a Solana Kamino vault position for the supplied wallet address, or null when Kamino reports no position. # Get Kamino yield position PnL Source: https://docs.spritz.finance/api-reference/wallet-kit/get-kamino-yield-position-pnl https://platform.spritz.finance/openapi.json get /v1/wallet-kit/yield/solana/kamino/positions/{vaultId}/pnl Returns aggregate PnL and cost basis for one Solana Kamino vault position. # Get Kamino yield vault Source: https://docs.spritz.finance/api-reference/wallet-kit/get-kamino-yield-vault https://platform.spritz.finance/openapi.json get /v1/wallet-kit/yield/solana/kamino/vaults/{vaultId} Returns one Solana Kamino vault. Unlisted vaults return 404 unless `includeUnlisted=true` is supplied. # Get wallet portfolio snapshots Source: https://docs.spritz.finance/api-reference/wallet-kit/get-wallet-portfolio-snapshots https://platform.spritz.finance/openapi.json get /v1/wallet-kit/portfolio/snapshots Returns the authenticated user's portfolio snapshot history for the requested range. `latest` and `baseline` reference the newest and oldest snapshots inside the range; `changeUsd`/`changePercent` are simple balance deltas (not P&L). New users with no snapshots return `latest`/`baseline` as null and an empty `points` array. # List Kamino yield position history Source: https://docs.spritz.finance/api-reference/wallet-kit/list-kamino-yield-position-history https://platform.spritz.finance/openapi.json get /v1/wallet-kit/yield/solana/kamino/positions/{vaultId}/history Returns time-series metrics for one Solana Kamino vault position. # List Kamino yield positions Source: https://docs.spritz.finance/api-reference/wallet-kit/list-kamino-yield-positions https://platform.spritz.finance/openapi.json get /v1/wallet-kit/yield/solana/kamino/positions Returns Solana Kamino vault positions for the supplied wallet address. # List Kamino yield transactions Source: https://docs.spritz.finance/api-reference/wallet-kit/list-kamino-yield-transactions https://platform.spritz.finance/openapi.json get /v1/wallet-kit/yield/solana/kamino/transactions Returns Kamino vault activity for the supplied Solana wallet address. `vaultId` is optional. # List Kamino yield vault history Source: https://docs.spritz.finance/api-reference/wallet-kit/list-kamino-yield-vault-history https://platform.spritz.finance/openapi.json get /v1/wallet-kit/yield/solana/kamino/vaults/{vaultId}/history Returns time-series metrics for one Solana Kamino vault. # List Kamino yield vaults Source: https://docs.spritz.finance/api-reference/wallet-kit/list-kamino-yield-vaults https://platform.spritz.finance/openapi.json get /v1/wallet-kit/yield/solana/kamino/vaults Returns listed USDC Kamino vaults on Solana by default. Set `includeUnlisted=true` to include unlisted vaults. # List Solana wallet transfers Source: https://docs.spritz.finance/api-reference/wallet-kit/list-solana-wallet-transfers https://platform.spritz.finance/openapi.json get /v1/wallet-kit/solana/transfers Returns a paginated list of Solana transfers involving the supplied address (sent or received). Results are cached briefly per address + page and invalidated when the web3 service emits `wallet-balance.invalidated`. # List wallet accounts Source: https://docs.spritz.finance/api-reference/wallet-kit/list-wallet-accounts https://platform.spritz.finance/openapi.json get /v1/wallet-kit/accounts Returns the on-chain addresses derived from the authenticated user's embedded wallet. `data` is empty when no wallet has been provisioned. # List wallet auth methods Source: https://docs.spritz.finance/api-reference/wallet-kit/list-wallet-auth-methods https://platform.spritz.finance/openapi.json get /v1/wallet-kit/auth-methods/ Returns the auth methods registered on the authenticated user's embedded wallet sub-organization. # List wallet token balances Source: https://docs.spritz.finance/api-reference/wallet-kit/list-wallet-token-balances https://platform.spritz.finance/openapi.json get /v1/wallet-kit/balances Returns multichain token balances for the supplied on-chain wallet address. Results are cached briefly per address. # Prepare add auth method Source: https://docs.spritz.finance/api-reference/wallet-kit/prepare-add-auth-method https://platform.spritz.finance/openapi.json post /v1/wallet-kit/auth-methods/intents Prepares a Turnkey activity for adding a new auth method (passkey, OAuth, or email). When a new method must be added, returns `status: "intent_required"` and an intent the wallet must stamp. When the same OAuth/email method already exists, returns `status: "already_exists"` with the existing method — no commit is needed. # Prepare email OTP start Source: https://docs.spritz.finance/api-reference/wallet-kit/prepare-email-otp-start https://platform.spritz.finance/openapi.json post /v1/wallet-kit/auth-methods/email/start/intents Prepares a Turnkey activity for starting the email-OTP flow. Stamp `intent.activityBody` and call the matching commit endpoint to dispatch the OTP. # Prepare remove auth method Source: https://docs.spritz.finance/api-reference/wallet-kit/prepare-remove-auth-method https://platform.spritz.finance/openapi.json post /v1/wallet-kit/auth-methods/{id}/intents Prepares a Turnkey activity for removing an auth method. Stamp `intent.activityBody` and call DELETE with the same `intentId` and stamp. Returns 409 if the method is the user's last (`LAST_AUTH_METHOD`). # Prepare Solana transaction Source: https://docs.spritz.finance/api-reference/wallet-kit/prepare-solana-transaction https://platform.spritz.finance/openapi.json post /v1/wallet-kit/solana/transactions/prepare Prepares an unsigned Solana v0 message for the authenticated user's embedded wallet to sign. Supports `transfer`, `payment`, `yield.deposit`, and `yield.withdraw` intents. Kamino is the only yield protocol supported today. The response `messageBytes` are base64-encoded Solana message bytes, not a serialized transaction; the wallet must sign the decoded bytes exactly and submit only the detached signature. # Provision embedded wallet Source: https://docs.spritz.finance/api-reference/wallet-kit/provision-embedded-wallet https://platform.spritz.finance/openapi.json post /v1/wallet-kit/ Provisions a new embedded wallet for the authenticated user from a passkey challenge. Returns 409 if a wallet already exists. # Submit Solana transaction Source: https://docs.spritz.finance/api-reference/wallet-kit/submit-solana-transaction https://platform.spritz.finance/openapi.json post /v1/wallet-kit/solana/transactions/submit Submits the user's detached signature for a previously prepared Solana transaction. The server verifies the signature against the stored message, adds any Spritz-owned signatures such as the fee payer, serializes the transaction, and broadcasts it. If submit returns `TX_EXPIRED`, call prepare again and sign the new `messageBytes`. Retrying after a successful broadcast returns the existing transaction signature. # Submit Solana transaction by id Source: https://docs.spritz.finance/api-reference/wallet-kit/submit-solana-transaction-by-id https://platform.spritz.finance/openapi.json post /v1/wallet-kit/solana/transactions/{txId}/submit Path-based submit alias. Use this when the prepared transaction id is already part of the URL. The request body contains only `userSignature`; `txId` is taken from the path. # ACH debit user experience Source: https://docs.spritz.finance/guides/ach-debit-user-experience Handle every ACH debit state in your UI, email, and push notifications. An ACH debit deposit has two independent timelines: * the bank debit; * the crypto delivery. Never collapse them into one spinner. Crypto can arrive before the debit settles, and a settled debit can still be returned later. ## Source of truth Use the successful create response for the first screen. After that, use current API reads: * `GET /v1/deposits/{depositId}` for the bank and crypto lifecycle; * `GET /v1/funding-sources/{sourceId}` for whether the bank can be used again; * `GET /v1/integrator/ach-debit/returns/{returnId}` for return handling. Subscribe to `onramp.created`, `onramp.updated`, `onramp.completed`, `achDebitReturn.created`, and `achDebitReturn.updated` to synchronize current state. Subscribe separately to `achDebit.authorized`, `achDebit.deliveryProgress`, `achDebit.delivered`, `achDebit.refunded`, and `achDebit.returned` for push notifications. Every webhook can arrive more than once or out of order. For an `onramp.*` event: 1. Fetch the current on-ramp by its webhook `id`. 2. If `source.depositId` is present, fetch that deposit with the user's authorization. 3. Update your stored resource and UI idempotently. Do not infer a push notification from these generic events. Use the semantic `achDebit.*` event's immutable snapshot, stable `eventId`, and per-deposit `sequence`. For an `achDebitReturn.*` event, fetch the return with integrator authentication. Use its `depositId` and `sourceId` to refresh the related resources. ## Amounts to display | UI value | Field or calculation | | - | - | | Total bank debit | `totalDebitAmountUsd` | | Crypto purchased | `principalAmountUsd` and `asset` | | Fee paid by the user | `userFeeUsd` | | Crypto confirmed onchain | `confirmedReleasedAmountUsd` | | Crypto still deliverable | `max(0, principalAmountUsd - confirmedReleasedAmountUsd)` | | Blockchain proof | `payoutTxHash`, when present | | Bank progress | `debitStatus` | | Crypto delivery progress | `releaseStatus` | `releasedAmountUsd` includes releases submitted to the blockchain but not necessarily confirmed. Do not tell the user that amount was delivered. Use `confirmedReleasedAmountUsd` for delivered amounts and `payoutTxHash` as proof when it is available. ## Deposit choice Keep the amount screen simple. Offer at most two choices: | Choice | API priority | User promise | | - | - | - | | **Standard** | `normal` | The full amount is delivered after the bank debit settles. | | **Fastest available** | `high` | Spritz delivers as much as currently available before settlement and the remainder after settlement. | Do not expose W1/W2, risk tiers, `high.maxAmountUsd`, or `high.maxEarlyReleaseAmountUsd` as separate product concepts. Use the maximum for the selected priority only to validate the amount. Then prepare the deposit and show the exact returned `instantPortionUsd`, `settlementPortionUsd`, `userFeeUsd`, and `totalDebitAmountUsd` before the user authorizes it. For example, render a prepared $600 Fastest available deposit as “$100 delivered early; \$500 after the bank debit settles.” The user chooses the delivery option, not the split. Also show the network and shortened wallet address. Use the funding source's `institution.name` and `accountNumberLast4` when available; omit either value when it is absent instead of showing a placeholder. Shorten wallet addresses to the first 6 and last 4 characters, separated by `…` (for example `AbCd12…9XyZ`). Keep the full address available to copy and in an accessible label. Never use the shortened form as a unique key or identity check. Format `availableAt` and `clearsAt` in the user's app locale and configured timezone (fall back to the device timezone), including a short timezone name. Use “after \[date]” for `availableAt` and exact clear times. When `clearsAtIsEstimate` is `true`, use “around \[date]” so the UI does not turn a forecast into a promise. Use these grammatical bank labels in copy: | Available fields | Phrase | | - | - | | Institution and last four | `from Chase ••••6789` / `to Chase ••••6789` | | Institution only | `from Chase` / `to Chase` | | Last four only | `from bank account ••••6789` / `to bank account ••••6789` | | Neither | `from your bank account` / `to your bank account` | ## State machines The top-level `status` is useful for lists. The two component statuses explain what is actually happening. ### Bank debit | `debitStatus` | Meaning | User-facing treatment | | - | - | - | | `authorized` | The user authorized the debit; it has not been submitted. | “Bank debit authorized.” | | `submitting` | Spritz is submitting the debit. | “Bank debit processing.” | | `submitted` | The bank debit is in flight. | “Bank debit processing.” | | `settled` | The debit settled, but it can still be returned later. | “Bank debit settled.” Do not call it irreversible. | | `returned` | The bank returned the debit. | Use the return record's `userAction`. | | `failed` | The debit failed before settlement. The deposit is terminal. | “The bank debit failed. No new debit was started.” Do not retry the existing deposit or show a retry button from this state. | `debitFailureCode` and `debitFailureReason` are diagnostic provider values, not a closed public enum. Log them for support. Do not build user copy or control flow around them. ### Crypto delivery | `releaseStatus` | Meaning | User-facing treatment | | - | - | - | | `not_started` | No crypto release has been queued. | Pending only while delivery can still progress. | | `queued` | A release is waiting or being submitted. | “Crypto delivery pending.” No promised deadline. | | `partial` | Some, but not all, crypto is confirmed. | Show delivered and remaining amounts separately. | | `completed` | The full principal is confirmed onchain. | “Crypto delivered.” Show `payoutTxHash` when present. | | `failed` | The release stopped before the full principal was delivered. | Label the remainder “not delivered,” not “pending.” | `releaseFailureCode` and `releaseFailureReason` are diagnostic provider values. Do not branch user copy on them. ### Top-level deposit | `status` | Meaning in a list or history view | | - | - | | `authorized` | The user authorized the bank debit. | | `processing` | The debit or crypto delivery is still progressing. | | `partially_released` | Some crypto is confirmed and some remains pending. | | `completed` | Crypto delivery completed. The bank can still return the debit later. | | `failed` | The debit or crypto release failed. Inspect both component statuses. | | `refunded` | Spritz initiated a refund of the bank debit; bank processing time varies. | | `returned` | The bank returned the debit. Fetch the return record. | Do not infer the component state from `status`. For example, a deposit can have a settled bank debit and a failed crypto release, or completed crypto delivery and a bank debit that later returns. ## Screens and messages Use these states as the minimum complete frontend contract: | Current state | Suggested message | | - | - | | Create succeeded | “You authorized a $506.00 debit to purchase $500.00 of USDC.” | | `processing`, nothing confirmed | “Your bank debit is processing. Crypto delivery is pending.” | | Some crypto confirmed, delivery can continue | “$100.00 of USDC delivered. $400.00 remains pending.” | | All crypto confirmed, debit not settled | “\$500.00 of USDC delivered. Your bank debit is still processing.” | | All crypto confirmed, debit settled | “Crypto delivered and bank debit settled.” | | Release failed with nothing confirmed | “This deposit could not be completed. No crypto was delivered.” | | Release failed after partial confirmation | “$100.00 of USDC was delivered. The remaining $400.00 was not delivered.” | | Debit failed before settlement | “The bank debit failed. No new debit was started. Contact support if you need help.” | | `refunded` | “A \$506.00 refund to your bank was initiated. Bank processing times vary.” | | `returned` | Explain the returned debit, delivered crypto, and the next action from `userAction`. | Do not call an authorized debit “withdrawn,” “submitted,” or “completed.” Do not call a partial delivery complete. Keep completed deposits visible in history because a later ACH return can change them. For high-priority deposits, render `priority`, `releaseDecisionMode`, both portions, `userFeeUsd`, and `totalDebitAmountUsd` from the **create response**. They can differ from the earlier preparation. A partially instant result or downgrade to normal is a successful deposit, not an error. ## Pre-deposit UI copy These outcomes happen before a deposit exists. Show them in the active flow; do not send email or push. Replace bracketed values with current structured API fields. Private risk, provider, reserve, and integration-policy inputs are not returned; never infer them or put raw diagnostics in user copy. “Contact support” always means the support channel inside **your app**. Configure one `supportHref` before launch and use it for every support CTA below. Your support team escalates to Spritz with the user ID, funding-source/deposit ID when one exists, and the response's `X-Request-ID` / `X-Correlation-ID`. Do not send the end user to an undocumented Spritz operations channel. ### Bank linking and funding sources | Outcome | Message | CTA | | - | - | - | | Source `pending` | “We’re verifying this bank account.” | **Check again** | | Source `review_required` / ownership uncertain | “We couldn’t confirm this bank account automatically. ACH deposits are paused while we review it.” | **Contact support** | | `ownership_mismatch` | “The name on this bank account doesn’t match your verified profile.” | **Link a different bank** | | `PLAID_ACCOUNT_ALREADY_LINKED` | “This bank account is already linked. Choose it to continue.” | **Choose bank account** | | `PLAID_ACCOUNT_REROUTED` / source `rerouted` | “This bank account is temporarily unavailable. Use a different bank account or try again after \[availableAt].” Omit the date when structured `availableAt` is unavailable. | **Choose another bank** | | `PLAID_ACCOUNT_BLOCKED`, source `disabled`, or `permanent: true` | “This bank account can’t be used for ACH deposits.” | **Choose another bank** and **Contact support** | | `PLAID_ACCOUNT_LIMIT_EXCEEDED` | “You’ve reached the linked bank account limit. Choose an existing bank account.” | **Choose bank account** | | `PLAID_ACCOUNT_LINK_INCOMPLETE` | “We couldn’t finish linking this bank account.” | **Contact support** | | `PLAID_DUPLICATE_IDENTITY` | “We couldn’t link this bank account. Use your existing Spritz account or contact support.” | **Contact support** | | `PLAID_DUPLICATE_BANK_ACCOUNT` | “This bank account is already linked to another Spritz account.” | **Contact support** | | `PLAID_ACCOUNT_HOLDER_UNAVAILABLE` | “We couldn’t verify ownership of this bank account.” | **Link a different bank** | | `PLAID_VERIFIED_IDENTITY_UNAVAILABLE` | “Your verified profile needs attention before you can link a bank account.” | **Contact support** | | `PLAID_LINK_FAILED`, `PLAID_SANDBOX_LINK_UNAVAILABLE`, `PLAID_IDENTITY_UNAVAILABLE` | “We couldn’t link this bank account right now. Try again or choose a different bank.” | **Try again** and **Choose another bank** | | Source `deleted` | “This bank account was removed.” | **Link a bank account** | ### Amount, availability, and deposit decisions | Outcome | Message | CTA | | - | - | - | | `validation_error` | “Check the highlighted information and try again.” | Focus the returned `field` | | `minimum_deposit` | “Enter at least \$\[minAmountUsd].” | Keep amount entry open | | `transaction_limit` | “Enter no more than \$\[maxAmountUsd].” | Keep amount entry open | | Settlement/exposure limit with `clearsAt` | “Your current ACH deposit limit is \$\[maxAmountUsd]. You can try again after \[clearsAt].” | **Try later** | | Settlement/exposure limit without `clearsAt` | “Your current ACH deposit limit is \$\[maxAmountUsd].” | Enter an allowed amount or choose the documented `suggestedAction` | | `rail_halted`, `ach_debit_program_halted`, `ach_debit_new_user_paused`, `new_user_admission_paused` | “ACH bank deposits are temporarily unavailable.” | **Choose another deposit method** | | `source_pending` | “This bank account is still being verified.” | **Check again** | | `source_review_required`, `risk_review_required` | “This bank deposit needs review.” | **Contact support** | | `source_rerouted`, `risk_rerouted` | “This bank account is temporarily unavailable for deposits.” | **Choose another bank**; show **Try after \[availableAt]** when present | | `source_disabled`, `source_not_eligible` | “This bank account can’t be used for ACH deposits.” | **Choose another bank** | | `source_not_found` | “This bank account is no longer available.” | Re-list and **Choose another bank** | | `ach_debit_source_identity_ambiguous` | “We found a problem with your linked bank accounts.” | **Contact support** | | `ach_debit_source_unsettled_limit` | “A debit from this bank account is still processing.” | **Try again after it settles** | | `risk_rejected` | “This bank deposit is unavailable.” | **Choose another deposit method** | | `risk_evaluation_unavailable`, `integration_unavailable` | “We couldn’t authorize this bank deposit right now.” | **Try again later** | | `ach_debit_user_access_restricted`, `ach_debit_user_access_disabled` | “ACH bank deposits are unavailable for your account.” | **Contact support** | | `preparation_expired` | “This authorization expired. Review and authorize the deposit again.” | **Review deposit** | | `preparation_not_found` | “We couldn’t find this authorization. Review and authorize the deposit again.” | **Review deposit** | | `preparation_already_used` | “This authorization was already used. Check your deposit history before trying again.” | **View deposit history** | | `integration_not_approved`, `integration_amount_limit`, `integration_usage_limit` | “ACH bank deposits are temporarily unavailable.” | **Choose another deposit method**; notify your operator to contact Spritz | | `integration_state_conflict` | “We couldn’t confirm whether your deposit was authorized. Check your deposit history before trying again.” | **View deposit history** | | Unknown or code-less problem | “We couldn’t complete this request. Check your deposit history before trying again.” | **View deposit history** or **Contact support** | For `daily_limit`, `monthly_limit`, `unsettled_deposit_limit`, `unsettled_amount_limit`, `bank_unsettled_deposit_limit`, `open_exposure`, and `aggregate_exposure`, re-read `limitsByPriority` and fill the copy from its public `maxAmountUsd`, `suggestedAction`, and `clearsAt`. Do not expose which private control produced that current limit. ## Email and push notifications Channel ownership for linked-bank ACH debit is fixed: * **Spritz sends transactional emails** for `achDebit.authorized`, `achDebit.deliveryProgress`, `achDebit.delivered`, `achDebit.refunded`, and `achDebit.returned` after email delivery is enabled for your production integrator. * **Your integration sends push notifications** in your app from the same milestones. * **Your integration owns the durable in-app history and status UI.** Confirm with Spritz that ACH email delivery is enabled before production testing. Do not send duplicate integrator emails for these milestones. In sandbox, assert the milestone and copy inputs; email delivery itself is not part of the public sandbox contract. Do not derive these notifications by fetching state after `onramp.updated`. Persist every semantic webhook `eventId` in a unique inbox and the highest accepted `sequence` per deposit. In the same transaction, enqueue one push keyed by `eventId`. Duplicate event IDs and sequences at or below the high-water mark produce no push. This prevents both a repeated partial-delivery push and a stale partial push after full delivery. ### Authorized For push, handle `achDebit.authorized`. Its snapshot matches the successful create, not the earlier preparation. **Title:** You authorized a \$506.00 bank debit “You authorized a $506.00 debit from Chase ••••6789 to purchase $500.00 of USDC. $100.00 will be delivered now and $400.00 after the bank debit settles. Fee: \$6.00. No action needed.” **Title:** You authorized a \$510.00 bank debit “You authorized a $510.00 debit from Chase ••••6789 to purchase $500.00 of USDC. The crypto will be delivered now while the bank debit processes. Fee: \$10.00. No action needed.” **Title:** You authorized a \$505.00 bank debit “You authorized a $505.00 debit from Chase ••••6789 to purchase $500.00 of USDC. The crypto will be delivered after the bank debit settles. Fee: \$5.00. No action needed.” ### Partial delivery Handle `achDebit.deliveryProgress`. Spritz creates a new occurrence only when `confirmedReleasedAmountUsd` increases above zero but remains below `principalAmountUsd`. **Title:** Part of your USDC was delivered When the debit is not settled: > $100.00 of USDC was delivered to your wallet. $400.00 remains pending until the bank > debit settles. No action needed. When the debit is settled: > $100.00 of USDC was delivered to your wallet. The remaining $400.00 is being > delivered. No action needed. Each legitimate increase has a new `eventId` and higher `sequence`. ### Full crypto delivery Handle `achDebit.delivered`. Its immutable snapshot has `releaseStatus: "completed"` and `confirmedReleasedAmountUsd` at least `principalAmountUsd`. **Title:** Your USDC was delivered If the debit is not settled: > Your full $500.00 of USDC was delivered to your wallet. Your $506.00 bank debit is > still processing. No action needed. If the debit is settled: > Your $500.00 of USDC was delivered to your wallet, and your $506.00 bank debit > settled. No action needed. When an instant deposit later settles without another delivery change, update the in-app bank status. A second email or push is usually unnecessary. ### Failure and refund For a release failure, state exactly how much was confirmed and how much was not delivered. Do not promise a refund merely because delivery failed. Failure copy is currently for the in-app state only: Spritz does not send a failure email, and your integration must not synthesize a push from `onramp.updated`. Do not add an integrator email or push for failures until Spritz publishes a semantic failure event. **Title:** Your USDC delivery could not be completed With nothing confirmed: > Your USDC delivery could not be completed. No USDC was delivered. Check the bank > status shown with this deposit. Contact support if you need help. After a partial confirmation: > $100.00 of USDC was delivered to your wallet. The remaining $400.00 was not > delivered. We’re looking into it. Contact support if you need help. For a debit failure before settlement, the deposit is terminal. Do not retry it or tell the user to retry the same authorization: **Title:** Your bank debit failed > Your \$506.00 bank debit failed. No new debit was started. Contact support if you need > help. Handle `achDebit.refunded`; do not infer the notification from current deposit status: **Title:** Your \$506.00 bank debit refund was initiated > A \$506.00 refund to Chase ••••6789 was initiated. Bank processing times vary. No > action needed. `status: "refunded"` and `achDebit.refunded` both mean the bank refund was initiated, not that the user received it. This is the final public refund milestone; there is no later refund-completed or refund-failed status or semantic event. Keep the deposit in history with “initiated” wording, and route a reported non-receipt through support. ### ACH return Handle `achDebit.returned`. Use its immutable `deposit` and `achReturn` snapshots for the push; use the resource webhooks and API reads separately to refresh the UI. **Title:** Your bank returned an ACH debit Build the body from exactly two parts: one crypto-state sentence, followed by one `userAction` sentence. Calculate amounts from the event's `deposit.confirmedReleasedAmountUsd`; `achReturn.cryptoStateAtReturn` selects the wording but is not itself an amount snapshot. | `cryptoStateAtReturn` | First sentence | | - | - | | `not_released` | “Your bank returned the \$506.00 debit before any USDC was delivered.” | | `in_flight` | “Your bank returned the \$506.00 debit while USDC delivery was in progress; check your deposit for the latest confirmed amount.” | | `partially_confirmed` | “Your bank returned the $506.00 debit after $100.00 of USDC was delivered; \$400.00 was not confirmed as delivered.” | | `fully_confirmed` | “Your bank returned the $506.00 debit after $500.00 of USDC was delivered.” | Never accuse the user of fraud or expose a return/risk reason. | `userAction` | Suggested message | | - | - | | `none` | “This bank account can no longer be used. Link another bank account to continue.” | | `review_required` | “ACH deposits are paused while we review it. Contact support for help.” | | `restricted` | “ACH deposits are temporarily restricted. Contact support for help.” | | `disabled` | “ACH deposits are disabled. Contact support for help.” | ### Access restored If support later restores access, update the in-app state only after fresh reads show all three: 1. the user's `fiat_to_crypto` / `ach_debit` capability is `active`; 2. at least one funding source is `active`; and 3. that source's `limitsByPriority.normal.available` is `true`. Do not synthesize an email or push from those reads. Wait for Spritz to publish a semantic access-restored event. Never promise restoration while review is pending. ## Keep pre-deposit outcomes in the active flow Do not send email or push for an outcome that occurs before a deposit exists. Show it where the user is choosing a bank, amount, or authorization: * bank-link and ownership outcomes; * validation and limit errors; * funding-source eligibility errors; * high priority reduced or changed to normal; * temporary integration or program pauses. Follow `suggestedAction`, `clearsAt`, `availableAt`, and `permanent` when present. Do not show internal decision details or suggest changing the amount to probe a decline. The deposit API does not expose a stable expected-delivery deadline. Do not decide a deposit is late from a local timer. Continue showing its current bank and crypto state, and reconcile from the API after missed webhooks. ## Frontend completion checklist * [ ] Authorization shows the exact create-response debit, principal, fee, timing, and destination. * [ ] Bank and crypto progress are rendered separately. * [ ] Partial delivery shows confirmed and remaining amounts. * [ ] A failed remainder is labeled “not delivered,” not “pending.” * [ ] Normal, partial-instant, full-instant, and high-to-normal outcomes are supported. * [ ] Returned and refunded deposits remain visible in history. * [ ] Return handling branches on `userAction`, not raw reason text. * [ ] Resource webhook processing reconciles current API state; semantic push processing permanently deduplicates `eventId` and rejects stale `sequence` values. * [ ] Spritz ACH email delivery is confirmed enabled; the integrator sends no duplicate emails and owns push. * [ ] Every message gives the next action or says “No action needed.” ## Related Build the complete backend and frontend flow. Handle return classes and user access changes. Verify, deduplicate, and reconcile deliveries. Run deterministic scenarios and the documented contract-only cases before launch. # ACH returns Source: https://docs.spritz.finance/guides/ach-returns Understand, track, and reconcile returned ACH debits. ACH is not a real-time, final network. An ACH debit can be **returned** after it settles: the account holder's bank sends the money back, sometimes days or weeks later, for reasons like insufficient funds or a claim that the debit wasn't authorized. Because a [linked bank on-ramp](/guides/use-cases/linked-bank-onramp) delivers crypto against an ACH pull, a return can mean funds you already moved need to be reconciled. This page explains how returns work and how Spritz surfaces them. ## When returns arrive Returns land within windows set by the ACH rules, which vary by reason: * **Administrative** returns (wrong or closed account, invalid number) usually arrive within about two banking days. * **Unauthorized** returns (the account holder disputes the debit) can arrive up to 60 calendar days after settlement. Spritz groups returns into three buckets, `unauthorized`, `administrative`, and `other`, so you can treat them differently. ## How Spritz surfaces a return When a debit is returned: * The deposit's `status` and `debitStatus` become `returned`, and the deposit carries a `returnCode` and `returnReason`. * The funding source becomes `disabled`, with `statusReason: "returned"` and `permanent: true`. Read the ACH code from the deposit or return record; private source-disablement inputs are not returned. * The `achDebitReturn.created` [webhook](/guides/webhooks) fires. `achDebitReturn.updated` can fire as details change. Delivery order is not guaranteed. A deposit can move through `completed` before its bank later returns the debit. Keep processing deposit and return webhooks after completion. The aggregate deposit can also show `failed` or `refunded` because of its crypto-release path while its `debitStatus` is already `settled`; a later ACH return still moves it to `returned`. Use `debitStatus` and `releaseStatus` to explain the current state. None of these aggregate states makes a settled ACH debit permanently immune to a later return. You can also list returns directly: ```bash theme={null} # Add HMAC headers as documented in Authentication. Integrator endpoints have no user Authorization. curl "https://platform.spritz.finance/v1/integrator/ach-debit/returns?returnBucket=unauthorized" ``` Fetch one return by the public `dr_...` ID from its webhook: ```bash theme={null} # Add HMAC headers as documented in Authentication. Integrator endpoints have no user Authorization. curl "https://platform.spritz.finance/v1/integrator/ach-debit/returns/dr_01JV7Q8M4Y8K6N2Z5P3R1T9W0X" ``` Each return record ties the return back to its deposit and quantifies your exposure: | Field | Meaning | | - | - | | `depositId`, `onRampId`, `userId`, `sourceId` | What and who the return relates to | | `returnCode`, `returnReason` | The ACH return reason (see below) | | `reportingBucket` | `unauthorized`, `administrative`, or `other` | | `occurredAt` | When the return was received | | `cryptoStateAtReturn` | Whether the crypto had already left: `not_released`, `in_flight`, `partially_confirmed`, or `fully_confirmed` | | `lossAmountUsd`, `atRiskAmountUsd` | Realized loss and still-at-risk amount | | `sourceAction` | The action applied to the funding source; currently `disabled` | | `userAction` | What happened to the user's ACH access: `none`, `review_required`, `restricted`, or `disabled` | `cryptoStateAtReturn` is the key exposure signal: a return before the crypto is released costs you nothing, while a return after `fully_confirmed` delivery is a realized loss. Store `returnCode` for reconciliation and support, but branch the user experience only on `userAction`. Treat `returnReason` as supporting text for logs and support tooling, not as a stable machine-readable value. ## User and frontend handling Every return disables the bank account that produced it. Cache invalidation can make a funding-source read briefly lag the returned deposit, so reconcile it with bounded polling after either `achDebitReturn.*` webhook. Never offer the source again once it is disabled. `userAction` is the authoritative result for the user's wider ACH access. Spritz's escalation policy is intentionally not public; do not derive or reproduce it from return codes or return counts. Use direct, action-oriented copy: | `userAction` | Suggested message | | - | - | | `none` | “This bank account can no longer be used. Link another bank account to continue.” | | `review_required` | “ACH deposits are paused while we review it. Contact support for help.” | | `restricted` | “ACH deposits are temporarily restricted. Contact support for help.” | | `disabled` | “ACH deposits are disabled. Contact support for help.” | Do not show a retry button for the disabled source. For `none`, offer another bank. For every other action, show the support path. ## Common return codes ACH return codes follow the NACHA standard (`R01`, `R02`, and so on). The ones you'll see most: | Code | Reason | | - | - | | `R01` | Insufficient funds | | `R02` | Account closed | | `R03` | No account or unable to locate account | | `R04` | Invalid account number | | `R05` | Unauthorized debit to a consumer account using a corporate SEC code | | `R07` | Authorization revoked by the customer | | `R08` | Payment stopped | | `R10` | Customer advises the debit is unauthorized | | `R16` | Account frozen | | `R20` | Non-transaction account | | `R29` | Corporate customer advises not authorized | This is a subset. The full set of codes is defined by NACHA, and `returnReason` carries the human-readable text for whatever code applies. ## Preventing avoidable returns Only debit accounts the user linked and clearly authorized. Treat a blocked deposit as final for that attempt: do not vary amounts or retry in a loop. Reconcile every return promptly and follow `userAction`; Spritz may pause a source, user, or integration before any new money moves. ## Reconciling Subscribe to `achDebitReturn.created` and `achDebitReturn.updated` so you learn about returns and synchronize current state. Subscribe to `achDebit.returned` separately when your integration sends push notifications. Never infer a return push from a generic return update. Key your record by the return `id`. Fetch `GET /v1/integrator/ach-debit/returns/{id}` for authoritative state whenever either event arrives. Reconcile the paginated `GET /v1/integrator/ach-debit/returns` feed after outages or missed deliveries. Use `cryptoStateAtReturn` and `lossAmountUsd` to decide whether the return is a real loss or was caught before delivery. Remove the disabled source from the payment picker. Branch on `userAction` to offer a different bank or the support path. # Amounts and currencies Source: https://docs.spritz.finance/guides/amounts How Spritz represents money in requests and responses. Money in the Spritz API is always a **decimal string**, never a number. Fiat amounts are formatted with two decimal places, like `"100.00"` or `"1000.50"`. Representing money as strings avoids the rounding errors that come with floating-point numbers. ## Working with amounts * **Send and receive amounts as strings**: `"100.00"`, not `100` or `100.0`. * **Parse them with a decimal or big-number type**, never a native float. Summing or comparing floats can silently lose cents. * Every fiat amount comes with a currency alongside it. ```json theme={null} { "output": { "amount": "100.00", "currency": "USD" }, "fees": { "amount": "1.25", "currency": "USD" } } ``` ## Currencies Fiat amounts use ISO 4217 currency codes. Spritz currently works with `USD`, `CAD`, `EUR`, and `GBP`. See [What we support](/guides/supported) for the rails and regions behind each. ## On-chain amounts Amounts you send or receive on-chain follow each blockchain's own conventions, so a token value in transaction parameters may be in the token's base units rather than a decimal string. Use the values exactly as the API returns them when building the transaction, and check them against the human-readable fiat amounts in the quote. See the [off-ramp guide](/guides/use-cases/off-ramp) and the [API Reference](/api-reference) for the fields on each endpoint. # Authentication Source: https://docs.spritz.finance/guides/authentication How your platform authenticates with the Spritz API. The Spritz API is primarily a backend-to-backend integration. In most cases your servers talk directly to Spritz, acting on behalf of your [users](/guides/definitions#user), and you authenticate those requests by signing them with HMAC. There are also times when you want a user's own device (their browser or mobile app) to call Spritz directly. For those cases you mint a short-lived token on your backend, or receive a capability from a prepare operation, and hand it to the device. The device then uses that credential to authenticate its own request. ## Two integration shapes Both are fully supported, and neither is "the advanced one" — pick per use case: * **Server-to-server**: your backend talks to Spritz with HMAC, stores what it needs, and serves your clients however you like. Webhooks keep your copy in sync. Choose this when you want to own the data model, decorate Spritz data with your own, or present one unified API to your clients. The trade-off: you're maintaining a mirror, and mirrors drift — webhooks plus periodic reconciliation against the API keep you honest. * **Client-direct tokens**: your backend does the minimum — create Spritz users and keep the reference — and mints a short-lived token when a client needs data. Choose this when you don't want to be in the data business. The trade-off: clients handle token refresh, and you see less of what users are doing without querying the API. They aren't mutually exclusive. Many integrators run both: server-to-server for the money-moving and compliance-sensitive paths (creating users, adding bank accounts, initiating payouts), client-direct tokens for read-heavy screens (transaction history, status polling). Client-direct is not a lesser path — the Spritz app itself uses the public API exactly this way, so it's a well-trodden, well-optimized route. Linked-bank ACH deposit creation is a deliberate exception: its scoped `ach_submit_` capability lets the authorizing client submit one prepared deposit directly, so Spritz can observe the client's network rather than your backend's. ## Acting on behalf of your users Almost everything in the Spritz API happens on behalf of one of your users. When you create an off-ramp, connect a bank account, or fund a card, you're doing it for a specific user, and your credentials tell Spritz which user that is. The exception is the **integrator endpoints**. Those are about you, not your users. They cover things like your integrator profile, your API keys, and your webhook configuration. A simple way to hold the distinction: user endpoints act on your users' resources, and integrator endpoints manage your own integrator account. Because integrator endpoints aren't tied to a user, you **don't send a user API key** with them. You sign them with your integrator credentials only (the three signing headers below, without the `Authorization` header). For a backend request to a user-scoped endpoint, HMAC and the user bearer are one combined authentication mode: all four headers are required. A short-lived `spr_` integrator token is a separate frontend authentication mode and does not use HMAC. ## Backend to backend with HMAC This is the primary way to use the Spritz API. Your backend signs each request with HMAC-SHA256, so Spritz can confirm it really came from you and that nothing was changed in transit. Signing produces three headers. Alongside them you send `Authorization` to identify the user you're acting for (this one isn't part of the signature). | Header | Value | | - | - | | `X-Integrator-Key` | Your integrator API key (`ik_…`) | | `X-Timestamp` | Unix timestamp in milliseconds | | `X-Signature` | `sha256=`, computed as below | | `Authorization` | `Bearer `, the user you're acting on behalf of | ### How the signature is built You sign a canonical string that pins together the timestamp, method, path, and a hash of the body: ``` {timestamp}.{METHOD}.{path}.{bodyHash} ``` * `timestamp` is the same value you send in `X-Timestamp` * `METHOD` is the HTTP method in uppercase (`GET`, `POST`, and so on) * `path` is the request path including its query string, with query params sorted by key and URL-encoded (for example `/v1/off-ramps?limit=10&status=pending`) * `bodyHash` is the SHA-256 hex digest of the raw request body, or an empty string when there's no body Duplicate query keys are not allowed on HMAC-signed requests. The API rejects them with `401`; use one value per key. ### Example Here's the full signing algorithm. It uses the Web Crypto API, so it runs as-is on Node 18+, Bun, Deno, Cloudflare Workers, and browsers. ```ts theme={null} const SIGNATURE_PREFIX = "sha256=" function hexEncode(buffer: ArrayBuffer): string { return Array.from(new Uint8Array(buffer)) .map((b) => b.toString(16).padStart(2, "0")) .join("") } async function sha256Hex(data: string): Promise { const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data)) return hexEncode(hash) } async function hmacSha256Hex(secret: string, data: string): Promise { const enc = new TextEncoder() const key = await crypto.subtle.importKey( "raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ) return hexEncode(await crypto.subtle.sign("HMAC", key, enc.encode(data))) } // Query params are sorted by key and URL-encoded before signing. function buildPathWithQuery(url: URL): string { const entries = [...url.searchParams.entries()] if (entries.length === 0) return url.pathname const seen = new Set() for (const [key] of entries) { if (seen.has(key)) throw new Error(`Duplicate query parameter: ${key}`) seen.add(key) } const query = entries .sort(([a], [b]) => a.localeCompare(b)) .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) .join("&") return `${url.pathname}?${query}` } export async function stampRequest( integratorKey: string, integratorSecret: string, method: string, url: string, body?: string | null, ) { const path = buildPathWithQuery(new URL(url)) const timestamp = Date.now() const bodyHash = body ? await sha256Hex(body) : "" const payload = `${timestamp}.${method.toUpperCase()}.${path}.${bodyHash}` const signature = `${SIGNATURE_PREFIX}${await hmacSha256Hex(integratorSecret, payload)}` return { "X-Integrator-Key": integratorKey, "X-Signature": signature, "X-Timestamp": String(timestamp), } } ``` Then send the signed headers along with the `Authorization` header for the user: ```ts theme={null} const url = "https://platform.spritz.finance/v1/off-ramps" const body = JSON.stringify({ /* request fields */ }) const signed = await stampRequest( process.env.SPRITZ_INTEGRATOR_KEY!, process.env.SPRITZ_INTEGRATOR_SECRET!, "POST", url, body, ) const res = await fetch(url, { method: "POST", headers: { ...signed, "Content-Type": "application/json", Authorization: `Bearer ${userApiKey}`, }, body, }) ``` Your timestamp has to be within 5 minutes of Spritz's server time, otherwise the request is rejected. This keeps a captured request from being replayed later. ## From a user's device with an integrator token Sometimes you'd rather have the user's device call Spritz directly instead of routing everything through your backend. For that, your backend mints a short-lived integrator token (it starts with `spr_`) and passes it to the device. The device then sends it in the `Authorization` header — no signing headers, no user API key: ```bash theme={null} curl https://platform.spritz.finance/v1/users/me \ -H "Authorization: Bearer spr_…" ``` Because the token is short-lived and minted per user, you can safely hand it to a browser or mobile app without exposing your backend credentials. It works on permitted user-scoped endpoints, including quote creation and transaction parameters. It cannot create a direct ACH deposit; use the preparation-bound capability below for that operation. ### Minting a token Exchange HMAC-signed integrator credentials (no user bearer — this is an integrator endpoint) for a token scoped to one user: ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/integrator/tokens \ -H "Content-Type: application/json" \ # plus your integrator signing headers -d '{ "userApiKey": "ak_…", "expiresIn": 3600 }' ``` ```json theme={null} { "accessToken": "spr_eyJ…", "userId": "63d12d3b577fab6c6382136e", "tokenType": "Bearer", "expiresIn": 3600, "expiresAt": "2026-09-03T17:00:00.000Z" } ``` `expiresIn` is optional, in seconds, default and maximum `3600` (one hour). An unknown `userApiKey` — or one for a user that isn't yours — gets a `400`, not a `401`. Minting is rate-limited to 100 requests per minute per integrator, so mint per session, not per request. ## From a user's device with an ACH submission capability `POST /v1/deposits/direct/prepare` can return a short-lived `submissionToken` beginning with `ach_submit_`. Your backend passes it and the `preparationId` to the authorizing client. The client uses it only for `POST /v1/deposits/direct`: ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/deposits/direct \ -H "Authorization: Bearer ach_submit_…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: your-unique-deposit-intent-key" \ -d '{ "preparationId": "prep_…" }' ``` The capability is bound to one user, integration, preparation, expiry, and the client network supplied during prepare. It cannot call sibling deposit routes or create a different preparation. See [Linked bank on-ramp](/guides/use-cases/linked-bank-onramp) for IP capture, direct submission, and the temporary HMAC backend fallback. ## When something's wrong A missing or invalid credential returns a `401`. A valid credential that isn't allowed to touch a particular resource returns a `403`. Both come back as [problem responses](/guides/errors) you can parse the same way as any other error. Every credential failure — bad signature, unknown key, expired or stale timestamp, tampered body — returns the same generic `401` with `detail: "Authentication failed"`. That's deliberate: the response never reveals which part failed. When you hit a mystery `401`, check in this order: 1. **Clock drift.** `X-Timestamp` must be within 5 minutes of Spritz server time, in milliseconds. A drifting container clock is the most common cause. 2. **Body serialization.** Sign the exact bytes you send. If your JSON serializer reorders keys or adds whitespace between signing and sending, the body hash won't match. 3. **Query canonicalization.** Params sorted by key, URL-encoded, no duplicate keys — duplicates are a `401`, not a last-wins. 4. **The user bearer.** User-scoped endpoints need all four headers; integrator endpoints are signed with the three signing headers only — no `Authorization`. # Definitions Source: https://docs.spritz.finance/guides/definitions Spritz terms and what they mean. The Spritz API uses a handful of terms of its own, especially across the API reference. This page defines them and links to where each one is used. ## Parties ### Integrator You, the company integrating with the Spritz API. You act on behalf of your users. See [Authentication](/guides/authentication). ### User Your end customer, the person whose money is moving. You onboard users and act on their behalf. See [Onboarding](/guides/onboarding). ## Money movement ### On-ramp Fiat to crypto. Spritz supports on-ramps through auto-ramp accounts (funded by ACH, wire, or SEPA) and through linked bank accounts pulled via ACH debit. See [On-ramp](/guides/use-cases/on-ramp) and [Linked bank on-ramp](/guides/use-cases/linked-bank-onramp). ### Off-ramp Crypto to fiat. A single off-ramp settles to one destination: a bank account, a bill, a Spritz card, or a debit card. See [Off-ramp](/guides/use-cases/off-ramp). ### Off-ramp quote A request to off-ramp to a destination. It captures the intent to pay along with the fulfillment terms, the fees, the amounts, and the address to send crypto to. You create a quote, then fulfill it on-chain. See [Off-ramp](/guides/use-cases/off-ramp). ### Deposit The on-ramp counterpart to an off-ramp quote, for ACH debit. It's the intent to pull funds from a linked bank account to on-ramp. See [Linked bank on-ramp](/guides/use-cases/linked-bank-onramp). ## Accounts and destinations ### Bank account A user's bank account. It's a fiat destination for off-ramps, and it can also be offered as a funding source for on-ramps. ### Funding source A bank account that Spritz can pull funds from to on-ramp, via ACH debit. See [Linked bank on-ramp](/guides/use-cases/linked-bank-onramp). ### Bill A biller account, such as a credit card, loan, or utility, used as a fiat destination for off-ramps. See [Bill pay](/guides/use-cases/bill-pay). ### Card A Spritz-issued card (also called a crypto card), funded with crypto and spendable anywhere. It's also an off-ramp destination. See [Cards](/guides/use-cases/cards). ### Debit card A user's external debit card, used as a payout destination for off-ramps. See [Push to debit card](/guides/use-cases/push-to-debit). ### Auto-ramp account Spritz's name for a virtual account. It's a virtual bank account in the user's name. Send fiat to it, and when the fiat arrives it auto-converts to crypto and delivers it to the wallet address and chain you choose. This is the on-ramp side. See [On-ramp](/guides/use-cases/on-ramp). ### Auto-ramp address The inverse of an auto-ramp account. It's a blockchain address that auto-off-ramps: send a stablecoin to it and Spritz converts it to fiat and settles it to the linked account. Each payable account (for example a bank account) gets its own auto-ramp address. Currently this is EVM chains only, and it's the same address on every network Spritz supports. ## Access ### Integrator Connect Spritz's OAuth flow that lets an existing Spritz platform user grant you access to act on their behalf, instead of you creating a new user. See [Onboarding](/guides/onboarding#connect-an-existing-spritz-user). ## Environments ### Sandbox The test environment, with its own base URL and credentials, for building against simulated rails. See [Sandbox](/guides/sandbox). # EEA onboarding Source: https://docs.spritz.finance/guides/eea-onboarding Onboard European (EEA) users end to end: verification, terms, MiCA compliance, and SEPA payouts — all driven by capabilities. Onboarding a user in the European Economic Area (EEA) uses the same capability-driven model as [any other user](/guides/onboarding) — you just have a couple of extra requirements to satisfy, because EEA ramps run under the EU's Markets in Crypto-Assets regulation (MiCA). You never hard-code these steps. You read the user's **capabilities**, satisfy whatever requirements are outstanding, and re-read until the ramp is `active`. This guide walks the full EEA path for a SEPA off-ramp; the shape is identical for on-ramps. New to the capability model? Read [Onboarding customers](/guides/onboarding) first — this guide assumes you've created the user and started identity verification. ## The extra requirements For an EEA user, a ramp capability (e.g. `crypto_to_fiat` / `sepa_credit_transfer`) can surface up to three requirements. Each tells you how to satisfy it: | Requirement `type` | How you satisfy it | | - | - | | `identity_verification` | Standard KYC (see [Onboarding](/guides/onboarding)). Usually already done; occasionally the provider asks for more — send the user to its `actionUrl`. | | `terms_acceptance` | Send the user to its `actionUrl` (a hosted terms flow) → it returns a **signed agreement id** → `POST /v1/users/me/terms`. | | `regional_compliance` | Collect the MiCA fields and `POST /v1/users/me/compliance`. No `actionUrl` — you render the form. See [Regional compliance](/guides/regional-compliance). | `terms_acceptance` and `regional_compliance` are the two that need you to **submit** something back — the rest of the model is the familiar "visit `actionUrl`, then re-fetch capabilities". ## The flow Create the user and start identity verification exactly as in [Onboarding customers](/guides/onboarding). Verification produces the identity data the EEA path needs, so it comes first. `GET /v1/users/me`. The SEPA capability comes back `requirements_needed` with its outstanding requirements and a `nextRequirement`. ```json theme={null} { "product": "crypto_to_fiat", "method": "sepa_credit_transfer", "status": "requirements_needed", "nextRequirement": "terms_acceptance", "requirements": [ { "type": "terms_acceptance", "actionUrl": "https://…", "status": "not_started" }, { "type": "regional_compliance", "actionUrl": null, "status": "not_started" } ] } ``` Send the user to the `terms_acceptance` requirement's `actionUrl` (a hosted terms page). When they accept, the flow returns a **signed agreement id**. Submit it: ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/users/me/terms \ -H "Content-Type: application/json" \ # plus your integrator signing headers and the user's Authorization -d '{ "agreementId": "" }' ``` ```json theme={null} { "termsAccepted": true } ``` The `agreementId` is opaque — you just relay what the hosted flow gave you. Accepting the terms is what provisions the user's EEA account behind the scenes. Check what's outstanding, collect the fields, and submit them together: ```bash theme={null} curl https://platform.spritz.finance/v1/users/me/compliance/requirements ``` ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/users/me/compliance \ -H "Content-Type: application/json" \ -d '{ "placeOfBirth": { "country": "DEU", "city": "Berlin" }, "nationalities": ["DEU"], "accountPurpose": "personal_or_living_expenses" }' ``` See [Regional compliance](/guides/regional-compliance) for the full field reference (ISO alpha-3 codes, the account-purpose list, and validation). Identity is normally satisfied by the verification from step 1. If the provider's own checks need more — for example an address that didn't match a registry — the `identity_verification` requirement stays with an `actionUrl`. Send the user there to complete it. You don't need to poll for this. Subscribe to the `capabilities.updated` [webhook](/guides/webhooks) and re-fetch `GET /v1/users/me` when it fires — you'll be notified as each requirement is processed and cleared. Once every requirement clears, the SEPA capability flips to `active` and money movement unblocks. Until then, transactions are refused with the outstanding requirement. Add the destination bank account and create the off-ramp — see [Off-ramp: crypto to bank](/guides/use-cases/off-ramp). **Listen, don't poll.** Provider checks (KYC, the SEPA endorsement, database screens) resolve asynchronously, sometimes minutes apart. Rather than polling `GET /v1/users/me` on a timer, subscribe to the `capabilities.updated` [webhook](/guides/webhooks) — Spritz notifies you whenever a requirement or capability status changes, and you re-fetch only then. While a ramp capability is `requirements_needed`, money movement on it is **blocked** — the requirements are a hard gate, not a soft prompt. Drive your UI off `capabilities` so the user always sees exactly what's left. ## Endpoint reference | Purpose | Endpoint | | - | - | | Start identity verification | `POST /v1/users/me/verification-sessions` | | Read capabilities / requirements | `GET /v1/users/me` | | Accept terms | `POST /v1/users/me/terms` | | Compliance — what's needed | `GET /v1/users/me/compliance/requirements` | | Compliance — submit | `POST /v1/users/me/compliance` | | Add destination bank account | `POST /v1/bank-accounts` | | Off-ramp quote | `POST /v1/off-ramp-quotes` | ## Next The MiCA field reference — place of birth, nationalities, account purpose. The capability-driven model these steps build on. Put the active SEPA capability to work. React to capabilities.updated instead of polling. # Errors Source: https://docs.spritz.finance/guides/errors Parse Spritz problem responses consistently. The Spritz API returns errors as [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details: a standard, machine-readable JSON shape served as `application/problem+json`. Build your error handling against it once and it works across every endpoint. Accept `application/json` as a compatibility fallback. ## Problem shape ```json theme={null} { "type": "urn:problem-type:validation:invalid-request", "title": "Invalid Request", "status": 400, "detail": "Deposit exceeds the current maximum deposit amount.", "instance": "/v1/deposits/direct/prepare", "code": "transaction_limit", "field": "amountUsd" } ``` | Field | Description | | - | - | | `type` | A `urn:problem-type:` URI identifying the broad error category. | | `title` | Short, human-readable summary of the error category. | | `status` | HTTP status code, repeated in the body. | | `detail` | Human-readable explanation specific to this occurrence. | | `instance` | URI of the specific request that failed. | | `code` | A stable, endpoint-specific discriminator when present. ACH debit integrations should branch on this field. | | `field` | The request field associated with the problem, when present. | Some problems include **extension members** alongside the standard fields for additional context, for example `code`, `realm`, `scope`, `resourceType`, and `resourceId`. Treat unknown extension members as optional. ## Status codes | Status | Meaning | | - | - | | `400` | Malformed request, such as invalid parameters or body. | | `401` | Missing or invalid credentials. See [Authentication](/guides/authentication). | | `403` | Authenticated, but not permitted for this resource. | | `404` | Resource not found. | | `409` | Conflict, such as a duplicate or a state that can't transition. | | `422` | Validation failed on an otherwise well-formed request. | | `429` | Rate limited. See [Rate Limits](/guides/rate-limits). | | `5xx` | Something went wrong on Spritz's side. Safe to retry idempotent requests. | ## Handling errors Branch first on the HTTP status and `type`. When `code` is present, use it for the specific product outcome. Several ACH debit errors intentionally share the same broad problem `type`, so `type` alone cannot distinguish a limit, an ineligible bank, and a program pause. Never branch on `title`, `detail`, or provider failure text; those are human-readable and may change. `code` is optional. For a code-less `409`, treat the state as an unresolved concurrent conflict: wait, read the affected resource, and replay only an idempotent request with the same key and body. For any other unknown or code-less problem, show a safe generic message, and do not invent product behavior. Every response includes `X-Request-ID` and `X-Correlation-ID` headers. Persist both with failed operations and include them when your support team escalates to Spritz. They are transport metadata, not fields inside the Problem Details body. ```ts theme={null} const res = await fetch(url, { headers }); if (!res.ok) { const problem = await res.json(); switch (problem.code) { case "transaction_limit": await refreshDepositLimits() break; default: logForSupport(problem) } } ``` The linked-bank guide lists the complete ACH debit [`code` handling matrix](/guides/use-cases/linked-bank-onramp#when-a-deposit-is-blocked). # Fees Source: https://docs.spritz.finance/guides/fees How fees are quoted, shown, and subsidized. Fees are always quoted upfront. Before any money moves, the quote or prepare step shows exactly what the fee is, so you and your user can see the full cost before committing. There are no surprise charges after the fact. This page covers how fees behave in the API. For the actual rates your users pay, see [end-user pricing](/guides/pricing). ## Fees on an off-ramp An [off-ramp quote](/guides/use-cases/off-ramp) breaks the amounts into three parts: ```json theme={null} { "output": { "amount": "100.00", "currency": "USD" }, "fees": { "amount": "2.00", "currency": "USD" }, "input": { "amount": "102.00", "currency": "USD" } } ``` (`$100` by ACH in USDC on Base: 0.5% would be `$0.50`, so the `$2` minimum applies — see [end-user pricing](/guides/pricing).) * `output.amount` is what the destination receives * `fees.amount` is the fee (`"0.00"` when there's none) * `input.amount` is the total the user pays, and always equals `output.amount + fees.amount` Because the quote is explicit, you can show the user the fee and the total before they fulfill it. See [amount modes](/guides/use-cases/off-ramp) for controlling whether you specify the amount received or the total paid. ## Fees on an ACH deposit A [linked bank on-ramp](/guides/use-cases/linked-bank-onramp) [deposit](/guides/definitions#deposit) returns a fuller breakdown, since the fee can be split between the user and you: * `principalAmountUsd`: the value delivered as crypto * `grossFeeUsd`: the total fee before any subsidy * `feeSubsidyUsd`: the portion you cover on the user's behalf * `userFeeUsd`: what the user actually pays * `totalDebitAmountUsd`: what is pulled from the bank (`principalAmountUsd + userFeeUsd`) * `feeRateBps`: the fee rate applied, in basis points For high-priority deposits, the fee follows the actual release timing: * `regularPublishedFeeUsd` is the fee on the portion released after settlement * `instantPublishedFeeUsd` is the fee on the portion released before settlement * `publishedFeeUsd` is their sum before final adjustments and subsidy A deposit can be partially instant, so the two portions can have different rates in one quote. Spritz recalculates them at create if the effective release timing changes. Always show `userFeeUsd` and `totalDebitAmountUsd` from the create response, even when you already displayed the preparation summary. ## Subsidizing fees for your users Fee subsidy is enabled per integrator. Confirm it is enabled for your integration before sending `feeSubsidy`; otherwise prepare returns `403`. When enabled, you can cover part or all of a user's fee. Pass a `feeSubsidy` with the percentage of the fee to cover and an optional per-transaction cap: ```json theme={null} { "feeSubsidy": { "percentage": 100, "maxAmountUsd": "5.00" } } ``` * `percentage` is `0` to `100` * `maxAmountUsd` caps how much you'll cover per transaction The result shows up as `feeSubsidyUsd` (what you covered) and `userFeeUsd` (what remains for the user). For example, a `$3` fee with `percentage: 100` means the user pays `$0` and you cover `$3`; with a `$5` cap, an `$8` fee leaves the user paying `$3`. ### When a transaction doesn't succeed You pay a subsidy only for a transaction that succeeds. If a transaction fails and the user's payment is refunded, the subsidy is credited back on your next invoice. You don't need to call an API or contact Spritz to claim the credit. An ACH debit can also be [returned](/guides/ach-returns) after the crypto was delivered. In that case, you carry the loss on the delivered amount, as set out in your integrator agreement, and the fee portion is credited on your next invoice. ## Related The standard rates your users pay. How long each rail takes to settle. # Idempotency Source: https://docs.spritz.finance/guides/idempotency Safely retry requests without duplicating money movement. Money-moving requests should be safe to retry. If a request times out or your connection drops mid-flight, you often can't tell whether it went through. Send an idempotency key and Spritz guarantees the operation runs at most once. A retry with the same key returns the original result instead of doing the work again. ## Send an idempotency key Add an `Idempotency-Key` header, a unique value such as a UUID, to any mutating request (`POST`, `PUT`, `PATCH`, `DELETE`). Reuse the same key when you retry that request. ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/off-ramp-quotes/ \ -H "Content-Type: application/json" \ -H "Idempotency-Key: 3f9a1c2e-8b47-4d6a-9c1f-2b7e5a0d4e11" \ # plus your integrator signing headers and the user's Authorization (see Authentication) -d '{ "accountId": "...", "amount": "100.00", "rail": "ach_standard", "chain": "ethereum" }' ``` Idempotency applies to authenticated mutating requests that include the header. `GET` requests are already safe to repeat, so they don't need a key. ## What happens on a retry | Situation | Result | | - | - | | First request with a new key | Processed normally, and the response is stored for 24 hours | | Same key and same request, after it finished | You get the original response back, with the header `X-Idempotency-Replayed: true`. The operation does not run again | | Same key while the first request is still processing | `409` (`urn:problem-type:request-in-progress`). Wait and retry; a `Retry-After` header tells you how long | | Same key but a different request body | `422` (`urn:problem-type:idempotency-conflict`). A key is bound to one exact request | ## Scope and lifetime A key is bound to your auth context and the exact request (method, path, and body). If you send the same key with a different body, Spritz rejects it with a `422` rather than risk returning the wrong result. Keys are remembered for **24 hours**; after that, the same key is treated as new. ## Error responses ```json theme={null} { "type": "urn:problem-type:request-in-progress", "title": "Request In Progress", "status": 409, "detail": "A request with this idempotency key is currently being processed" } ``` ```json theme={null} { "type": "urn:problem-type:idempotency-conflict", "title": "Idempotency Key Conflict", "status": 422, "detail": "The provided idempotency key has been used with a different request" } ``` Both are standard [problem responses](/guides/errors). ## Best practices * **Generate a unique key per operation**, for example a UUID. * **Reuse the same key when you retry** the same operation, so the retry is deduplicated. * **Never reuse a key for a different operation.** * **Persist the key** until you've confirmed the outcome, so a crash and retry uses the same key. # Onboarding customers Source: https://docs.spritz.finance/guides/onboarding Create a user, verify their identity, and read their capabilities. Or connect an existing Spritz user. Every Spritz user belongs to your integration. Onboarding has three beats: create the user, verify their identity, and read their capabilities to see what they can do. If the person already has a Spritz account, with you or with another integrator, you don't create a new user. You [connect to them](#connect-an-existing-spritz-user) instead, with their consent. ## 1. Create a user Create a user with their email address. Spritz returns a user API key (prefixed `ak_`) that you use to authenticate requests on that user's behalf. ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/integrator/users \ -H "Content-Type: application/json" \ # plus your integrator signing headers (see Authentication) -d '{ "email": "user@example.com" }' ``` ```json theme={null} { "userId": "63d12d3b577fab6c6382136e", "email": "user@example.com", "apiKey": "ak_..." } ``` Store the `apiKey` securely. You send it as the `Authorization` bearer for every request you make on this user's behalf (see [Authentication](/guides/authentication)). You can also pass an optional `timezone`. ## 2. Verify identity Before a user can move money, they complete identity verification (KYC). Start a verification session for the user: ```bash theme={null} # Add HMAC headers and the user's Authorization as documented in Authentication. # This request has no body, so its HMAC body hash is the empty string. curl -X POST https://platform.spritz.finance/v1/users/me/verification-sessions/ ``` ```json theme={null} { "sessionId": "inq_2Q3x7k9m1n", "provider": "persona", "sessionToken": "...", "verificationUrl": "https://verify.spritz.finance/...", "verificationUrlExpiresAt": "2026-06-30T12:34:56.000Z" } ``` The response selects the provider for this session. Do not hard-code one provider: `provider` is either `persona` or `plaid`, and both `sessionToken` and `verificationUrl` are nullable. Use this dispatch contract: | Response | Integration action | | - | - | | `provider: "persona"` and `sessionToken` is present | Embed the Persona SDK. Pass `sessionId` as the inquiry ID and `sessionToken` as the session token. | | `provider: "plaid"` and `sessionToken` is present | Open Plaid Link and pass `sessionToken` as its `token`. Treat `sessionId` as an opaque verification-session ID. | | `verificationUrl` is present | Hosted fallback for either provider: open the returned URL in a browser tab or mobile web view. Do not construct a provider URL yourself. | | Both `sessionToken` and `verificationUrl` are null | Do not open an SDK. Show “We couldn’t start identity verification. Try again.” with **Try again**, which creates a fresh session. If a fresh request has the same result, use your app's **Contact support** route. | Prefer the provider SDK when you support it and a token is present; otherwise use the hosted URL. A non-null `verificationUrlExpiresAt` is authoritative. If that time has passed, create another session instead of opening the old URL. When it is null, still request sessions just in time and never persist a URL as a permanent verification link. Persona SDK references: [Web embedded flow](https://docs.withpersona.com/quickstart-embedded-flow), [React Native](https://docs.withpersona.com/embedded-flow/react-native) and [native mobile](https://docs.withpersona.com/embedded-flow/mobile-sdk). Plaid sessions use the same Plaid Link client integration described by Plaid's SDK documentation; the token comes from this verification-session response, not from the bank-account link-token endpoint. Creating a session can also return `409`. Branch on its stable `code`, never its `title` or `detail`: | `code` | Message | CTA | | - | - | - | | `VERIFICATION_UNDER_REVIEW` | “Your identity verification is under review.” | **Check again** — re-read `GET /v1/users/me`; do not create another session while it remains under review | | `VERIFICATION_SESSION_UNAVAILABLE` | “Identity verification can’t be started for this account.” | **Contact support** using the support route inside your app | The user's verification state is reported on their profile as `verification.status`: `not_started`, `verified`, `failed`, `retry`, or `disabled`. A `retry` status means they can try again; create a new session to do so. ## 3. Read capabilities `GET /v1/users/me` returns the user's verification status and their **capabilities**. Capabilities are the source of truth for what a user can do right now, and what's blocking anything they can't. ```json theme={null} { "id": "63d12d3b577fab6c6382136e", "email": "user@example.com", "verification": { "status": "verified", "country": "US" }, "capabilities": [ { "product": "crypto_to_fiat", "method": "ach_credit", "name": "ACH Bank Transfer", "description": "Pay out to a US bank account via ACH", "status": "active", "requirements": [] }, { "product": "fiat_to_crypto", "method": "ach_debit", "name": "ACH Debit", "status": "active", "requirements": [] } ] } ``` For an ACH-debit-enabled integrator, a verified US user receives the active `fiat_to_crypto` / `ach_debit` capability with no separate terms requirement. Other products and regions can still return `requirements_needed`; always drive the UI from the actual capability object instead of assuming every method has the same gates. Select ACH debit by the exact pair, not by array position or display name: ```ts theme={null} const achDebit = user.capabilities.find( (capability) => capability.product === "fiat_to_crypto" && capability.method === "ach_debit", ); ``` `method` is optional in the generic capability schema because non-ramp products do not have a transfer method. A missing `method` does not match ACH debit. If no capability has the exact pair above, treat ACH debit as `not_available`: do not start bank linking, and offer another supported deposit method. Each capability is a **product**, and for ramps a **method**: * **Products**: `fiat_to_crypto`, `crypto_to_fiat`, `bill_pay`, `crypto_card` * **Methods**: `ach_credit`, `ach_debit`, `wire`, `sepa_credit_transfer`, `rtp`, `push_to_card`, `canadian_eft` Its `status` tells you where it stands: | Status | Meaning | | - | - | | `active` | Ready to use now | | `requirements_needed` | The user has something to complete first | | `pending` | In progress, for example verification under review | | `not_available` | Not offered for this user (for example, their region) | For the ACH debit entry point, use this minimum copy and action contract: | State | Message | CTA | | - | - | - | | Verification `not_started` | “Verify your identity to use ACH bank deposits.” | **Verify identity** — create a verification session | | Verification `retry` | “We couldn’t verify your identity. Try again to continue.” | **Verify again** — create a new session | | Verification `failed` | “We couldn’t verify your identity.” | **Contact support** in your app | | Verification `disabled` | “Identity verification is unavailable for this account.” | **Contact support** in your app | | Verification `verified`, ACH capability `active` | No blocking message. | Continue to bank linking | | ACH capability `requirements_needed` | Show the selected requirement's `description`, or the type fallback below when it is absent. | Follow the selected requirement action below, then re-fetch the user. | | ACH capability `pending` | “Your ACH bank deposit access is being reviewed.” | **Check again**; do not start Plaid Link | | ACH capability `not_available` | “ACH bank deposits aren’t available for this account.” | Offer another supported deposit method | “Contact support” means the integrator's configured in-app support route. Its support team escalates to Spritz; do not invent a direct Spritz operations URL. When a capability is `requirements_needed`, its `requirements[]` lists what to do. `type` and `status` are always present. `description`, `actionUrl`, `retryable`, and the capability's `nextRequirement` are optional by contract. Choose the requirement whose `type` equals `nextRequirement` when that field is present and still incomplete. Otherwise choose the first requirement whose `status` is not `completed`. If there is no such requirement, re-fetch the user once. If the capability still says `requirements_needed` with no incomplete requirement, show “We couldn’t load the next verification step.” with **Contact support**; do not guess a step. Use a returned `description` when present. Otherwise use this fallback and action matrix: | Requirement type | Fallback message | Action when `actionUrl` is absent | | - | - | - | | `identity_verification` | “Verify your identity to continue.” | Create a verification session as described in step 2. | | `terms_acceptance` | “Accept the required terms to continue.” | The hosted terms flow is required to produce an agreement ID. Show **Contact support**; do not fabricate an agreement or mark terms accepted. | | `additional_verification` | “Additional verification is required.” | If `status` is `pending`, show **Check again**. Otherwise show **Contact support**. | | `document_submission` | “Supporting documents are required.” | If `status` is `pending`, show **Check again**. Otherwise show **Contact support**. | | `regional_compliance` | “Additional information is required for your region.” | Render the regional compliance form and submit it to `POST /v1/users/me/compliance`; see [EEA onboarding](/guides/eea-onboarding). | | `region_restriction` | “This product isn’t available in your region.” | No CTA clears it. Offer another supported product. | When `actionUrl` is present, open it for every non-terminal requirement, then re-fetch the user after the flow returns. A missing `retryable` is not permission to retry a failed provider action automatically. Only retry when it is `true` or when the explicit type/status handling above gives the user a retry action. ### Drive your UI from capabilities Render your onboarding and product UI off the capabilities list rather than tracking state yourself: * Show a capability as available when its `status` is `active`. * When it's `requirements_needed`, select and render the requirement using the fallback rules above. Never assume its optional presentation or action fields are present. * After the user completes a requirement, re-fetch `GET /v1/users/me`, or react to the `capabilities.updated` [webhook](/guides/webhooks) so you update without polling. ## Connect an existing Spritz user If the person already has a Spritz account, with you or with another integrator, use **Integrator Connect** to gain access with their consent instead of creating a new user. It's an OAuth-style authorization flow. From your backend, create a session with a pre-registered HTTPS redirect URI and an optional `state` value you can use to tie the result back to a request. ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/integrator/connect/sessions \ -H "Content-Type: application/json" \ # plus your integrator signing headers (see Authentication) -d '{ "redirectUri": "https://app.example.com/spritz/callback", "state": "opaque-value" }' ``` ```json theme={null} { "sessionId": "...", "authorizationUrl": "https://app.spritz.finance/connect?session_id=...", "expiresAt": "2026-06-30T12:40:00.000Z" } ``` Redirect the user to `authorizationUrl`. They sign in to Spritz, review your integration, and approve. The session is valid for about 10 minutes. On approval, Spritz redirects the user back to your `redirectUri` with a `code` and the `state` you sent. From your backend, exchange the code for that user's API key. The code is single-use and expires after a few minutes. ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/integrator/connect/token \ -H "Content-Type: application/json" \ # plus your integrator signing headers -d '{ "code": "..." }' ``` ```json theme={null} { "apiKey": "ak_...", "userId": "63d12d3b577fab6c6382136e", "email": "user@example.com", "grantId": "..." } ``` You now hold a user API key for that user and can act on their behalf, exactly like a user you created. ## Next Use the user API key to sign requests. Rails, tokens, and regions behind each capability. Put an active capability to work. React to capabilities.updated and verification changes. # Pagination Source: https://docs.spritz.finance/guides/pagination Page through list endpoints with cursors. List endpoints that can return many results are cursor-paginated. Each response returns one page of results plus a cursor you use to fetch the next page. ## Endpoints that aren't paginated Not every list endpoint is paginated. Collections that are bounded per user return a **bare JSON array** — no envelope, and no `limit` or `cursor` parameters: ``` GET /v1/api-keys/ GET /v1/auto-ramp-accounts/ GET /v1/auto-ramp-addresses/ GET /v1/bank-accounts/ GET /v1/bills/ GET /v1/cards/ GET /v1/funding-sources/ GET /v1/integrator/webhooks ``` Every other list endpoint — deposits, on-ramps, off-ramps, card transactions, webhook deliveries, debit cards and the wallet-kit collections — uses the cursor envelope below. If you write one generic list helper, branch on `Array.isArray(response)` rather than assuming `data` is always present. ## Response envelope Paginated responses wrap the results in an envelope: ```json theme={null} { "data": [ /* results for this page */ ], "hasMore": true, "nextCursor": "eyJpZCI6..." } ``` * `data`: the results for this page * `hasMore`: whether more results exist after this page * `nextCursor`: pass this value as `cursor` to fetch the next page. It's `null` — or omitted entirely on some endpoints — when there are no more results. Loop on `hasMore`, not on the cursor's presence. ## Fetch pages Set the page size with `limit` (the default is endpoint-specific), and fetch the next page by passing the previous response's `nextCursor` as `cursor`. ```bash theme={null} # First page curl "https://platform.spritz.finance/v1/off-ramps/?limit=50" \ # plus your integrator signing headers and the user's Authorization (see Authentication) # Next page curl "https://platform.spritz.finance/v1/off-ramps/?limit=50&cursor=eyJpZCI6..." \ # plus your integrator signing headers and the user's Authorization ``` Loop until `hasMore` is `false`: ```ts theme={null} let cursor: string | null | undefined let hasMore = true while (hasMore) { const url = new URL("https://platform.spritz.finance/v1/off-ramps/") url.searchParams.set("limit", "50") if (cursor) url.searchParams.set("cursor", cursor) const page = await signedGet(url) // send a signed GET, see Authentication for (const item of page.data) { // handle each result } cursor = page.nextCursor hasMore = page.hasMore } ``` Treat cursors as opaque. Don't construct or parse them; just echo `nextCursor` back as `cursor`. ## Filter and sort Many list endpoints accept filters and a sort order alongside pagination. For example, `GET /v1/off-ramps/` accepts `status`, `chain`, and `accountId`, plus `sort` (`asc` or `desc`). Filters apply consistently across pages. See each endpoint in the [API Reference](/api-reference) for its full set of parameters. # End-user pricing Source: https://docs.spritz.finance/guides/pricing The standard fees your users pay on Spritz transactions. This page lists the standard rates your end users pay. Fees are charged only when a user moves value — crypto to fiat, or fiat to crypto. There is no fee to hold a Spritz account. Every fee is quoted before the user commits. See [Fees](/guides/fees) for how the fee appears in a quote or deposit response, and how you can subsidize it. ## How a fee is calculated Each fee has two parts: a **percentage** of the transaction amount, and a **minimum** per-transaction amount. The fee charged is whichever is greater. ``` fee = max(rate × amount, minimum) ``` The rate is additive. Start from the base rate, then add every adjustment that applies — the rail used, the token and network, and the size of the transaction. The minimum is the largest of the base minimum and any minimum an adjustment sets. ## United States and Canada ### Crypto to fiat The base rate is **0.5%**, with a **\$2** minimum. Adjustments apply on top. **By rail** | Rail | Rate adjustment | Minimum | | - | - | - | | `ach_standard` — ACH to bank | — | \$2 | | `ach_same_day` — same-day ACH | +1% | \$2 | | `rtp` — RapidPay instant payout | +1% | \$2 | | `push_to_card` — push to card | +0.5% | \$2 | | `bill_pay` — bill payment | +0.5% | \$4 | **By token and network** | Network | Token | Rate adjustment | Minimum | | - | - | - | - | | Arbitrum, Avalanche, Base, Optimism, Polygon, Solana | USDC | — | \$2 | | Arbitrum, Avalanche, Base, Optimism, Polygon, Solana | All other tokens | +0.5% | \$2 | | Ethereum | All tokens | +0.5% | \$2 | | Tron | USDC | +0.5% | \$2 | | Tron | USDT | +1.5% | \$2 | | Sui | USDC | +1% | \$2 | | Sui | SUI | +2% | \$2 | | BNB Smart Chain | All tokens | +1% | \$2 | | DASH | DASH | +1.25% | \$2 | | Ripple | XRP | +1.5% | \$2 | | Bitcoin | BTC | +1.5% | \$5 | Stablecoins on low-cost networks are the cheapest way for a user to pay. If you choose which network to present, defaulting to USDC on one of the no-adjustment networks keeps your users' fees lowest. **By transaction size** | Size | Rate adjustment | | - | - | | Regular (≤ \$20,000) | — | | Large (> \$20,000 and ≤ \$100,000) | +0.5% | | Super (> \$100,000) | +1.0% | RapidPay (`rtp`), same-day ACH (`ach_same_day`), and push to card (`push_to_card`) cost an **additional 1%** on weekends and US bank holidays. Each user gets **one free instant transaction per month**, up to \$1,000, exempt from that 1% instant-rail adjustment and from the weekend and holiday surcharge. Standard fees still apply. ### Fiat to crypto The base rate is **1%**. Adjustments apply on top. | Funding | Rate adjustment | Minimum | Additional | | - | - | - | - | | ACH debit — released after settlement | — | \$2 | — | | ACH debit — released instantly (high priority) | +1% | \$2 | — | | Wire | — | — | \$20 | Instant release is available on [linked bank on-ramps](/guides/use-cases/linked-bank-onramp) in the United States only. A user who takes it pays **2%** in total. A high-priority deposit can be only partially instant. The +1% applies to the instant portion alone — the portion released after settlement stays at the 1% base rate. That is why one deposit returns both `instantPublishedFeeUsd` and `regularPublishedFeeUsd`. See [Fees](/guides/fees#fees-on-an-ach-deposit). ## Other supported countries These rates apply to users in Andorra, Australia, Austria, Belgium, Brazil, Cyprus, Czechia, Denmark, Finland, France, Germany, Gibraltar, Greece, Ireland, Italy, Luxembourg, Malta, the Netherlands, Norway, Poland, Portugal, Romania, Slovakia, Slovenia, Spain, Sweden, Switzerland, the United Arab Emirates, and the United Kingdom. | Transaction | Rate | Minimum | | - | - | - | | Bank transfer (crypto to fiat) | 1% flat | None | | On-ramp (fiat to crypto) | 1% flat | None | These rates are genuinely flat: no rail, token, network, or transaction-size adjustments apply outside the US and Canada. ## Worked examples * Base rate: 0.5% * `ach_standard`: no adjustment * USDC on Base: no adjustment * Regular size: no adjustment Rate is **0.5%** and the minimum is **\$2**. `max(0.5% × $2,000, $2)` = **\$10** * Base rate: 0.5% * `bill_pay`: +0.5%, minimum \$4 * Non-USDC on Arbitrum: +0.5% * Regular size: no adjustment Rate is **1.5%** and the minimum is **\$4**. `max(1.5% × $1,500, $4)` = **\$22.50** * Base rate: 0.5% * `ach_standard`: no adjustment * BTC on Bitcoin: +1.5%, minimum \$5 * Regular size: no adjustment Rate is **2%** and the minimum is **\$5**. `max(2% × $150, $5)` = **\$5**, because 2% of \$150 is only \$3. * Base rate: 1% * Instant release: +1% Rate is **2%** and the minimum is **\$2**. `max(2% × $500, $2)` = **\$10** * Base rate: 1% * Released after settlement: no adjustment Rate is **1%** and the minimum is **\$2**. `max(1% × $100, $2)` = **\$2**, because 1% of \$100 is only \$1. The two portions price separately: * Instant portion: `2% × $200` = **\$4** (`instantPublishedFeeUsd`) * Settlement portion: `1% × $300` = **\$3** (`regularPublishedFeeUsd`) Total fee is **\$7**. ## Related How fees are quoted, returned, and subsidized. Networks, tokens, rails, and regions. # Quickstart Source: https://docs.spritz.finance/guides/quickstart Make your first authenticated Spritz API call in minutes. This guide walks through your first authenticated request to the Spritz API. By the end you'll have confirmed your credentials work and read back the authenticated user. ## Prerequisites * A Spritz integrator account and credentials. See [Authentication](/guides/authentication) for how each credential type works and which one fits your integration. * A REST client (`curl`, Postman, or your language's HTTP library). Don't have credentials yet? [Get in touch](https://help.spritz.finance/en/) to set up an integrator account and start in the [sandbox](/guides/sandbox). The API base URL is `https://platform.spritz.finance`. All endpoints are versioned under `/v1`. The [API Reference](/api-reference) is generated from the deployed production contract. For client generation or environment-specific validation, use the canonical [production OpenAPI](https://platform.spritz.finance/openapi.json) or [sandbox OpenAPI](https://sandbox.spritz.finance/openapi.json) directly. These docs are machine-readable. `/llms.txt` indexes every page, any page is available as markdown by appending `.md` to its URL (for example `/guides/use-cases/off-ramp.md`), and the OpenAPI spec doubles as the source for generated clients — all three are handy things to hand to a coding agent. We publish an `api-client` package, and we'd rather you didn't use it right now: it's a mix of the older and current APIs, and that seam causes more confusion than it saves. Build against the REST API documented here, and generate typed clients from the OpenAPI spec if you want them — that stays in sync with reality. The one thing the package repo is good for: it contains working TypeScript for HMAC request signing, so if you're fighting `401`s, read that code as a reference implementation. Copy the signing logic; don't take the dependency. ## Make your first call `GET /v1/integrator` returns your integrator profile — the simplest way to confirm your credentials work, and it needs no user: ```bash theme={null} curl https://platform.spritz.finance/v1/integrator \ -H "X-Integrator-Key: $SPRITZ_INTEGRATOR_KEY" \ -H "X-Timestamp: $TIMESTAMP_MS" \ -H "X-Signature: $SIGNATURE" # see Authentication for signing ``` Once you're acting for a user, `GET /v1/users/me` reads the authenticated user: ```bash curl theme={null} curl https://platform.spritz.finance/v1/users/me \ -H "Authorization: Bearer $SPRITZ_TOKEN" ``` ```ts TypeScript theme={null} const res = await fetch("https://platform.spritz.finance/v1/users/me", { headers: { Authorization: `Bearer ${process.env.SPRITZ_TOKEN}` }, }); const user = await res.json(); ``` A successful response returns `200 OK`. If the credential is missing or invalid you'll get a `401` [problem response](/guides/errors). ## Next steps Understand HMAC signing and integrator tokens. Convert stablecoins and other tokens to fiat and settle to a bank account. Parse RFC 9457 problem responses consistently. Track settlement and status changes asynchronously. # Rate Limits Source: https://docs.spritz.finance/guides/rate-limits Generous request limits, the headers that report them, and how to handle 429s. Spritz's rate limits aim to be very generous, and most integrations never come close to them. If you expect high volume or run into a limit that gets in your way, reach out and we'll work with you. Contact us through the [Help Center](https://help.spritz.finance/en/). ## Prefer webhooks over polling The most common reason to hit a rate limit is polling the API for status changes. You usually don't need to. Spritz emits [webhooks](/guides/webhooks) for the events that matter (payments, on-ramps, verification, and more), so you can react to changes as they happen instead of polling in a loop. ## Reading the headers Every response includes your current rate-limit status, so you can pace requests without guessing: | Header | Meaning | | - | - | | `X-RateLimit-Limit` | The maximum number of requests allowed in the current window | | `X-RateLimit-Remaining` | How many requests you have left in the current window | | `X-RateLimit-Reset` | When the window resets, as an ISO 8601 timestamp | ```http theme={null} X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 994 X-RateLimit-Reset: 2026-06-30T12:34:56.000Z ``` ## When you exceed a limit If you go over, the API responds with `429 Too Many Requests` and a `Retry-After` header telling you how many seconds to wait. The body is a standard [problem response](/guides/errors): ```json theme={null} { "type": "urn:problem-type:rate-limit", "title": "Too Many Requests", "status": 429, "detail": "Rate limit exceeded. Please retry after 30 seconds.", "retryAfter": 30, "limit": 1000, "remaining": 0, "resetAt": "2026-06-30T12:34:56.000Z" } ``` Wait for the period in `Retry-After` before retrying, and back off exponentially if you keep getting `429`s. If you're hitting limits during normal operation, [get in touch](https://help.spritz.finance/en/), that's usually a sign we should raise your limits. # Regional Compliance Source: https://docs.spritz.finance/guides/regional-compliance Collect the extra identity details some regions require before a user can transact. Some regulatory regions require a few identity details beyond standard verification. Today that means users in the European Economic Area (EEA), under the Markets in Crypto-Assets regulation (MiCA) — but the mechanism is region-neutral: the same two endpoints handle any region Spritz adds. Everything else about your integration is unchanged. This is one extra step after [onboarding and verification](/guides/onboarding). This is a **blocking requirement**. Until the user submits these fields, their ramp capabilities (SEPA, ACH, wire) carry a `regional_compliance` requirement and money movement is refused. You can detect it two ways: it appears in the `requirements` array on the user's capabilities — so if you already gate on capabilities you get it for free — and the dedicated endpoint below gives the per-field detail you need to collect what is missing. ## The protocol The flow is the same for every region: Onboard and verify your user as normal (see [Onboarding customers](/guides/onboarding)). Once `verification.status` is `verified`, check for regional requirements. Call `GET /v1/users/me/compliance/requirements`. It reports whether this user's region requires anything and which fields are outstanding. If required, collect the listed fields and submit them together. If you already know the user is in a regulated region, you can skip the check and `POST` directly — the submit endpoint validates and rejects users for whom nothing is required. ## Check requirements ```bash theme={null} curl https://platform.spritz.finance/v1/users/me/compliance/requirements \ -H "Authorization: Bearer ak_USER_KEY" # plus your integrator signing headers (see Authentication) ``` ```json theme={null} { "required": true, "region": "EEA", "complete": false, "deadline": "2026-06-26", "fields": [ { "field": "placeOfBirth", "status": "missing" }, { "field": "nationalities", "status": "missing" }, { "field": "accountPurpose", "status": "missing" } ] } ``` Act only when `required && !complete`. Otherwise there is nothing to do — a user outside a regulated region returns `required: false, complete: true` with an empty `fields` array, and a user who has already submitted returns `complete: true`. | Field | Meaning | | - | - | | `required` | Whether the user's region requires anything. `false` outside such regions. | | `region` | The region driving the requirement (currently only `EEA`), or `null`. | | `complete` | Whether every required field has been collected. | | `deadline` | Bridge's own compliance date for the fields. It does **not** grant a grace period — an EEA user is gated as soon as any field is missing, regardless of this date. | | `fields` | Per-field status — each `missing` or `complete`. Empty when not required. | ## Submit the fields Submit all fields together. Validate on your side first — the request is rejected as a whole if anything is invalid. ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/users/me/compliance \ -H "Authorization: Bearer ak_USER_KEY" \ -H "Content-Type: application/json" \ # plus your integrator signing headers (see Authentication) -d '{ "placeOfBirth": { "country": "DEU", "city": "Berlin" }, "nationalities": ["DEU"], "accountPurpose": "personal_or_living_expenses" }' ``` ```json theme={null} { "complianceFieldsComplete": true, "bridgeCustomerUpdated": true } ``` `complianceFieldsComplete` is the same signal as `complete` from the requirements endpoint — once it is `true`, the `regional_compliance` requirement clears from the user's capabilities. `bridgeCustomerUpdated` reports whether the fields were forwarded to the provider immediately (`true`) or stored to be sent when the customer is first created (`false`); either way the fields are captured. After a successful submission, re-fetching requirements returns `complete: true`. ## EEA field reference These are the fields the EEA requires. A future region would list its own fields in the requirements response; this section is EEA-specific. ### placeOfBirth ```json theme={null} { "country": "DEU", "city": "Berlin" } ``` * **`country`** (required) — ISO 3166-1 **alpha-3** code. This is the three-letter code (`DEU`, `FRA`, `POL`), not the two-letter code (`DE`). This is the most common integration mistake. * **`city`** — optional today; required by EU law from 2027. ### nationalities ```json theme={null} ["DEU", "FRA"] ``` An array of ISO 3166-1 **alpha-3** codes, at least one. Include every nationality the user holds, not just their primary one. ### accountPurpose One of the following, plus a free-text `accountPurposeOther` when `other`: | Value | Suggested label | | - | - | | `personal_or_living_expenses` | Personal or living expenses | | `receive_salary` | Receiving salary | | `receive_payment_for_freelancing` | Receiving payment for freelance work | | `payments_to_friends_or_family_abroad` | Payments to friends or family abroad | | `purchase_goods_and_services` | Purchasing goods and services | | `ecommerce_retail_payments` | Online shopping and retail payments | | `operating_a_company` | Operating a company | | `investment_purposes` | Investing | | `protect_wealth` | Protecting wealth | | `charitable_donations` | Charitable donations | | `other` | Other (requires `accountPurposeOther`) | ```json theme={null} { "placeOfBirth": { "country": "FRA" }, "nationalities": ["FRA"], "accountPurpose": "other", "accountPurposeOther": "Family remittances" } ``` Use these exact `accountPurpose` values — anything else is rejected. Build your dropdown from this list. ## Errors Errors follow the standard [problem-details](/guides/errors) format. | Status | When | | - | - | | `400` | The submission is invalid (bad country code, empty nationalities, missing `accountPurposeOther`, or a purpose not in the list). | | `400` | Compliance fields submitted for a user whose region does not require them. | | `409` | The fields raced a concurrent change to the customer record. Safe to retry. | | `404` | No user record found. | ```json theme={null} { "type": "urn:problem-type:validation:invalid-request", "title": "Invalid Request", "status": 400, "detail": "Compliance field validation failed" } ``` The `detail` is a summary, not a per-field list — validate client-side (country codes alpha-3, at least one nationality, a description when the purpose is `other`) so your users see precise errors before the request is made. ## Next Create and verify the user before checking compliance. Parse problem responses consistently. # Sandbox & Testing Source: https://docs.spritz.finance/guides/sandbox Build and test against simulated rails before going live. Spritz provides a sandbox so you can exercise the full money-movement lifecycle (onboarding, conversion, settlement, cards) against simulated rails. Fiat never moves in sandbox. One exception applies on the crypto side: the off-ramp chain leg runs on mainnet, so it needs a small amount of real crypto. See [Testing the off-ramp](#testing-the-off-ramp). ## Base URL The sandbox is a separate environment with its own base URL and its own credentials: ``` https://sandbox.spritz.finance ``` Use your sandbox integrator credentials here, exactly as you would in production. When you're ready to go live, switch the base URL to `https://platform.spritz.finance` and swap in your production credentials. ## Skipping identity verification Real users must complete identity verification before they can move money. In the sandbox you can skip it to keep test flows fast: ```bash theme={null} # Simulate a successful US verification for the current user # Add body-specific HMAC headers and the user's Authorization as documented in Authentication. curl -X POST https://sandbox.spritz.finance/v1/sandbox/bypass-kyc \ -H "Content-Type: application/json" \ -d '{ "country": "US" }' # Use the same signed POST with one of these bodies for other outcomes: # { "country": "EU" } -> verified EEA user # { "failed": true } -> failed verification ``` Exactly one of `country` or `failed: true` is required. An empty body is a `400` — there is no default. ### Capability groups `country` selects a **capability group**: the set of offerings a verified user gets. It is not an ISO country code. `EU` is the EEA as a whole, because every member state sees the same offerings — passing a real country code like `DE` or `ES` is rejected with a `400`. | Group | Unlocks | Sandbox | | - | - | - | | `US` | Linked-bank ACH debit, ACH push and wire on-ramps. Payouts via ACH, RTP and push-to-debit-card. Crypto card. | Available | | `EU` | On-ramp and payout via SEPA credit transfer. Crypto card. Gated behind [EU regional compliance](/guides/regional-compliance). | Available | | `CA` | Canadian offerings. | `501` — no sandbox fixture yet | | `GB` | UK offerings. | `501` — no sandbox fixture yet | A `501` means the group is real but sandbox cannot simulate it yet; it is distinct from the `400` an unrecognised value gets, and it is not worth retrying. A temporary verification-service failure returns `503` with code `SANDBOX_KYC_UNAVAILABLE` and `retryable: true`. Do not treat this as a failed KYC decision. Read `GET /v1/users/me`; continue if the requested capability is already active, otherwise retry the same sandbox request with bounded backoff. The bypass changes verification state; it does not override product configuration. For an ACH-debit-enabled integrator, the US bypass makes the user's `fiat_to_crypto` / `ach_debit` capability active with no separate terms requirement. Other products or regions can still have requirements, including EEA regional compliance. Read [`GET /v1/users/me`](/api-reference) after the bypass to see each capability's `status` and outstanding `requirements`, and [`GET /v1/on-ramps/supported-pairs`](/api-reference) for the rail, network and token combinations the group can actually use. The bypass response is a legacy verification document with no public stable schema. Do not store or branch on it. Treat a `2xx` as “simulation accepted,” then read the typed user profile and capabilities from `GET /v1/users/me`. Sandbox-only endpoints return `403` in production. Don't build production flows that depend on them. ## Simulating the rails The sandbox also lets you drive the parts of a flow that normally depend on banks and networks, so you can test the unhappy paths on purpose. Sandbox-plan ACH deposits are isolated from accumulated program-control halts and other test users. One simulated return cannot silently make later scenarios fail for an unrelated user. Use the named profiles below to request each observable outcome; do not try to reproduce production decision logic with Plaid test data. ### Link a bank account without Plaid Link First bypass KYC with `{ "country": "US" }` and confirm the user's ACH debit capability is active. The sandbox bank-link endpoint rejects users in any other capability group. Use the sandbox link endpoint when an automated test cannot open Plaid Link: ```bash theme={null} # Add body-specific HMAC headers and the user's Authorization as documented in Authentication. curl -X POST https://sandbox.spritz.finance/v1/sandbox/bank-accounts/link \ -H "Content-Type: application/json" \ -d '{ "simulation": { "code": "ownership_matched" } }' ``` This creates a Plaid Sandbox item and runs the normal token exchange, account sync, ownership check, funding-source creation, event, and cache-invalidation paths. The funding source has no separate ownership field. The ownership decision appears in the funding source's `status` and `statusReason`, which is what the Funding source column below shows. Read them from `GET /v1/funding-sources/{fundingSourceId}`. Successful profiles accept `simulation.account: "primary" | "secondary"`. It defaults to `primary`; use `secondary` to link a distinct matched bank for the same user. Link both before destructive tests such as repeated returns. | `simulation.code` | Response | Funding source | What to test in your UI | | - | - | - | - | | `ownership_pending` | `200` | `status: "pending"`, `statusReason: null` | Show “We’re verifying this bank account”; disable deposits and offer a refresh action. | | `ownership_matched` | `200` | `status: "active"`, `statusReason: null` | Continue to limits and deposit entry. | | `ownership_uncertain` | `200` | `status: "review_required"`, `statusReason: "ownership_review_required"` | Show review/pending; do not allow a deposit. | | `ownership_mismatch` | `200` | `status: "ineligible"`, `statusReason: "ownership_mismatch"` | Explain that the account owner did not match; offer another bank. | | `link_failed` | `503`, code `PLAID_LINK_FAILED`, `retryable: true` | No bank account or funding source is created | Keep the user on linking; offer retry or another bank. | | `duplicate_identity` | `409`, code `PLAID_DUPLICATE_IDENTITY`, `retryable: false` | No bank account or funding source is created | Stop linking. Tell the user to use their existing account or contact support. | | `duplicate_bank_account` | `409`, code `PLAID_DUPLICATE_BANK_ACCOUNT`, `retryable: false` | No active bank account or funding source is created | Stop linking. Tell the user to use the account where this bank is already linked or contact support. | Ownership mismatch and review profiles do not claim the bank account. A different user who owns that bank can still link it successfully. Link the same successful account slot a second time to test duplicate handling. The second request returns `409` with `code: "PLAID_ACCOUNT_ALREADY_LINKED"`; it does not create another funding source and does not change the active source. Close Link and re-list funding sources instead of retrying. To test unlinking, delete the bank with `DELETE /v1/bank-accounts/{accountId}`. Its funding source remains readable with `status: "deleted"`. Linking the same healthy account slot again returns the original bank-account and funding-source IDs, restores the source to `active`, and leaves exactly one active source. Treat those IDs as stable; do not create a second local payment method. ### Simulate deposit decision outcomes Prepare a direct deposit, then send its `preparationId` to the sandbox create endpoint with one public outcome profile. These profiles select client-visible behavior; they do not identify the production provider input, policy rule, or threshold that caused it. ```bash theme={null} # Add body-specific HMAC headers and the user's Authorization as documented in Authentication. curl -X POST https://sandbox.spritz.finance/v1/sandbox/deposits/direct \ -H "Content-Type: application/json" \ -H "Idempotency-Key: your-unique-key" \ -d '{ "preparationId": "prep_...", "riskSimulation": { "profile": "review_required" } }' ``` | `riskSimulation.profile` | Public code | What to do | | - | - | - | | `review_required` | `risk_review_required` | Show review/support. Do not loop or immediately retry. | | `rejected` | `risk_rejected` | Show payment unavailable. Do not retry the same bank and amount. | | `source_temporarily_unavailable` | `risk_rerouted` | Offer another bank, or wait until the source's `availableAt`. | | `decision_unavailable` | `risk_evaluation_unavailable` | Show a temporary error. Prepare again and retry later. | | `high_priority_available` | No error; deposit created | Show the returned instant and settlement portions and the blended fee. | | `high_priority_downgraded` | No error; deposit created | Continue with standard timing. Render the fee and timing from the create response; do not ask the user to retry solely to recover instant release. | The first four profiles return `409` and create no deposit. A create attempt consumes its preparation. Reuse the same idempotency key and body only to recover after a timeout; prepare again for a new user attempt. Use both high-priority profiles with a high-priority preparation. `high_priority_available` returns the quoted instant split. `high_priority_downgraded` returns `200`, preserves `requestedPriority: "high"`, and sets: ```json theme={null} { "priority": "normal", "releaseDecisionMode": "after_settlement", "instantPortionUsd": "0.00", "settlementPortionUsd": "500.00" } ``` The returned regular, instant, and total fee fields are also recalculated for the actual release timing. Treat the create response as authoritative. ### Simulate refund and crypto-release states Prepare a **normal-priority** deposit for `refunded`, `release_failed`, `release_stalled`, or `full_delivery`, then create it with one lifecycle profile: ```bash theme={null} # Add body-specific HMAC headers and the user's Authorization as documented in Authentication. curl -X POST https://sandbox.spritz.finance/v1/sandbox/deposits/direct \ -H "Content-Type: application/json" \ -H "Idempotency-Key: your-unique-key" \ -d '{ "preparationId": "prep_...", "lifecycleSimulation": { "profile": "release_failed" } }' ``` | `lifecycleSimulation.profile` | Created state | What to test | | - | - | - | | `refunded` | `status: "refunded"`, `debitStatus: "settled"`, `releaseStatus: "not_started"` | Show that a refund was initiated. Do not imply it already reached the bank. | | `release_failed` | `status: "failed"`, `debitStatus: "settled"`, `releaseStatus: "failed"` | Show “not delivered,” not “pending.” Do not promise a refund. | | `release_stalled` | `status: "processing"`, `debitStatus: "settled"`, `releaseStatus: "queued"` | Keep delivery pending with no guessed deadline; reconcile by API read. | | `full_delivery` | `status: "completed"`, `debitStatus: "settled"`, `releaseStatus: "completed"` | Capture `achDebit.delivered` and re-read limits. Use this profile for the per-user exposure sequence below. | | `partial_then_full_delivery` | `status: "completed"`, `debitStatus: "settled"`, `releaseStatus: "completed"` | Capture `achDebit.deliveryProgress` followed by `achDebit.delivered` for one deposit and test ordering and deduplication. | These profiles create a real sandbox deposit record and make the resulting state available from both `GET /v1/deposits/{depositId}` and `GET /v1/deposits/`. They do not call a money-movement provider. The `refunded` profile also records the same durable `achDebit.refunded` semantic event used by the production refund path. Use the create response itself to assert `authorized`. Use `release_stalled` to assert the `processing` / `queued` UI. For deterministic partial and full delivery events, prepare a **high-priority** `500.00` deposit whose preparation contains positive `instantPortionUsd` and `settlementPortionUsd`, then create it with `lifecycleSimulation.profile: "partial_then_full_delivery"`. The final create response is completed, while the semantic webhook snapshots preserve the intermediate partial state and exact amounts. The profile emits the sequence `achDebit.authorized` → `achDebit.deliveryProgress` → `achDebit.delivered` without calling a crypto or bank provider. Separately run `riskSimulation.profile: "high_priority_available"` to exercise the provider-backed sandbox execution path. Poll the deposit every 2 seconds for up to 5 minutes and assert the final `confirmedReleasedAmountUsd`; do not require its transient partial state to last long enough for polling. The five-minute value is a test-harness observation bound, not a user delivery promise. ### Simulate program pauses Use the sandbox prepare endpoint with a normal deposit body plus one state: ```bash theme={null} # Add body-specific HMAC headers and the user's Authorization as documented in Authentication. curl -X POST https://sandbox.spritz.finance/v1/sandbox/deposits/direct/prepare \ -H "Content-Type: application/json" \ -d '{ "sourceId": "fs_...", "address": "YourUsersSolanaWallet", "network": "solana", "asset": "USDC", "quoteType": "exact_output", "amountUsd": "25.00", "priority": "normal", "programControlSimulation": { "state": "halted" } }' ``` | `programControlSimulation.state` | Response | UI behavior | | - | - | - | | `new_user_paused` | `409`, `ach_debit_new_user_paused` for a new sandbox user | Show temporary unavailability; offer another supported on-ramp. Do not expose user classification. | | `halted` | `409`, `ach_debit_program_halted` | Show temporary ACH debit unavailability. Do not retry in a loop. | No preparation or deposit is created in either blocked case. ### Simulate per-user open exposure Use one fresh user and one matched source. This sequence exercises the public `open_exposure` result without exposing or hardcoding a tier threshold: 1. Read `limitsByPriority.normal` and assert `available: true`. 2. Prepare `normal.maxAmountUsd` with `priority: "normal"`. 3. Create it through `/v1/sandbox/deposits/direct` with `lifecycleSimulation.profile: "full_delivery"`. Assert `status: "completed"`, `debitStatus: "settled"`, `releaseStatus: "completed"`, and `confirmedReleasedAmountUsd` equal to the prepared principal. 4. Re-read limits and repeat steps 2–3 at the new `normal.maxAmountUsd` while normal remains available. Use a fresh preparation and idempotency key each time. 5. The final normal block must return `available: false`, `maxAmountUsd: "0.00"`, `reason: "open_exposure"`, `suggestedAction: "auto_ramp"`, a non-null `clearsAt`, and `clearsAtIsEstimate: true`. Do not assert the number of deposits or infer a private exposure ceiling from their amounts. The current public limits decide each iteration. Use a fresh user for another run because completed deposits intentionally remain inside that user's open-exposure window. ### Simulate program and instant capacity Two sandbox-only endpoints let you force the public `aggregate_exposure`, `rail_halted`, partial-instant, and high-to-normal outcomes: Instant scenarios require Spritz to enable high priority on the sandbox integrator. There is no public self-service switch. Ask Spritz for an enabled sandbox credential; if `limitsByPriority.high.available` remains false, record instant scenarios as blocked by configuration rather than failed. | Method | Path | Purpose | | - | - | - | | `GET` | `/v1/sandbox/ach-debit/exposure` | Read your sandbox integrator's current cap and open-exposure values. | | `POST` | `/v1/sandbox/ach-debit/exposure/cap` | Replace `aggregateCapW2Usd` and `aggregateCapW1Usd` for that sandbox integrator. | The W1/W2 names are API field names for two isolated sandbox capacity controls. The endpoint does not describe how production policy is calculated. Never hardcode the returned values into product behavior. Cap changes affect every user under the authenticated sandbox integrator. Run these scenarios serially against a dedicated test integrator. Read both original values first and restore both in a `finally` block. If restoration fails, stop testing and restore them before another deposit test runs. Use this acceptance sequence: 1. Read and save the original response. 2. Read an active source's baseline `limitsByPriority`. 3. With high priority enabled, prepare an amount no greater than both `high.maxAmountUsd` and `high.maxEarlyReleaseAmountUsd`, then create with `riskSimulation.profile: "high_priority_available"`. Assert `early_full` and the full principal in `instantPortionUsd`. This is the deterministic full-instant case. 4. To test normal program capacity, set `aggregateCapW2Usd` to the current `openExposureW2Usd` plus a small headroom below the source's baseline maximum. Keep W1 unchanged. Re-read limits: the returned normal maximum should shrink. Preparing that maximum succeeds; one cent above returns `aggregate_exposure`. 5. Set `aggregateCapW2Usd` to `"0.00"`. Normal becomes unavailable with `reason: "rail_halted"` and `suggestedAction: "auto_ramp"`. 6. Restore the original W2 cap. Then set `aggregateCapW1Usd` to `committedExposureW1Usd` plus a small desired instant portion. Keep W2 unchanged. Re-read limits and verify `high.maxEarlyReleaseAmountUsd` is constrained while normal remains available. Prepare and create above that portion with `high_priority_available`; assert `early_partial`, the constrained `instantPortionUsd`, and the remainder in `settlementPortionUsd`. 7. Prepare another high-priority amount above that portion, then set W1 to the current `committedExposureW1Usd` before create. Create succeeds but changes to normal timing; render the create response without retrying. 8. Restore both original cap values, even if any assertion fails. ### Simulate ACH returns Prepare a normal direct deposit, then create it with a supported ACH return code: ```bash theme={null} # Add body-specific HMAC headers and the user's Authorization as documented in Authentication. curl -X POST https://sandbox.spritz.finance/v1/sandbox/deposits/direct \ -H "Content-Type: application/json" \ -H "Idempotency-Key: your-unique-key" \ -d '{ "preparationId": "prep_...", "returnSimulation": { "code": "R10" } }' ``` The deposit is created normally. The simulated bank then returns it asynchronously. Poll the deposit until `status: "returned"` and reconcile the `achDebitReturn.created` webhook or `GET /v1/integrator/ach-debit/returns`. Sandbox accepts `R01` through `R39`, plus `R45` and `R51`. All 41 codes run through the same provider-backed return path and have been exercised end to end. Use a fresh sandbox user for each independent return so a prior source or user action cannot affect the next case. Start with `R01`, `R05`, and `R10` as representative provider-path tracers. Assert that the return record contains a valid `userAction`, then route that value through the same handler as every other code. Test all four `userAction` branches with fixtures. Production return escalation policy is intentionally not public. Always branch on the returned `userAction`, never recreate a threshold from return history. Test `userAction: "restricted"` with a return-record fixture in your frontend contract suite. A simulated return can report `completed`, or an aggregate `failed`/`refunded` crypto release outcome, before changing to `returned`. Keep processing later deposit and return updates. After `returned`, boundedly reconcile the funding source until it is `disabled`; a cached source read can lag briefly. Use exactly one of `riskSimulation`, `returnSimulation`, or `lifecycleSimulation`. See [ACH returns](/guides/ach-returns) for the frontend contract. ### ACH debit release checklist Before production, record a pass for each row. Use fresh users except where a scenario explicitly tests repeated behavior. | Area | Required scenarios | | - | - | | User | Successful US verification, failed verification, missing ACH capability | | Bank link | Ownership matched, uncertain, mismatch, complete link failure, duplicate identity, duplicate bank, same-user duplicate, unlink/relink, blocked relink | | Funding source | `pending`, `active`, `review_required`, `ineligible`, `disabled`, `deleted` | | Amount | Exact minimum, one cent below minimum, exact maximum, one cent above maximum | | Priority | Normal, full instant, partial instant, high downgraded to normal, high unavailable | | Deposit decision | Review, reject, source temporarily unavailable, decision unavailable, high priority available, high priority downgraded | | Capacity | User/open exposure, aggregate exposure, rail halt, instant portion constrained, instant capacity exhausted | | Program | New-user pause and integration halt | | Lifecycle | Authorized, processing/queued, partial confirmation, full confirmation, refund, release failure, return after completion | | Returns | All 41 supported codes, every `userAction`, and an unknown-code fallback that still follows `userAction` | | Webhooks | Valid `Spritz-Signature`, stale timestamp rejected, secret rotation, `2xx`, `4xx`, `5xx`, timeout, retry then success, `eventId` deduplication, out-of-order handling, redeliver | | Reconciliation | `GET /v1/deposits/`, deposit read, return list/read, funding-source refresh after missed webhooks | | Communications | Authorized, partial delivery, full delivery, failed remainder, refund initiated, return action | The sandbox does not currently provide a deterministic debit-submission failure, unknown return-code, ACH notice-of-change, or access-restored profile. Record these as contract-tested rather than sandbox-executed: | State | Public contract test | | - | - | | Debit submission failure | Feed a deposit fixture with `debitStatus: "failed"` through the UI/notification logic. The deposit is terminal; show no retry button. | | Unknown return code | Feed an arbitrary `returnCode` through the return handler and prove the UI branches only on `userAction`. | | Restricted return action | Feed a return fixture with `userAction: "restricted"`; stop ACH deposits and show support. Do not encode a return-count threshold. | | ACH notice of change | There is no dedicated public NOC object or user action. Treat `account.updated` as a refresh trigger; show nothing unless the refreshed public bank/funding-source state changed. | | Access restored | Prove notification requires an active ACH capability, an active funding source, and `limitsByPriority.normal.available: true`. | ### Test webhook failure and recovery Use a disposable HTTPS receiver such as Webhook.site; you do not need to deploy test infrastructure. Register it for `achDebitReturn.created`, configure it to return `503`, then run an `R01` return simulation. Expect three signed `POST` attempts with the same payload and the same `Spritz-Delivery-Id`. Then read `GET /v1/integrator/webhooks/deliveries`: the matching record has `status: "pending"`, `attempts: 3`, `success: false`, `responseStatus: 503`, no `error`, and a `nextAttemptAt` about 30 seconds later. Switch the receiver to return `2xx` before `nextAttemptAt`. The next attempt carries the same `eventId` and `Spritz-Delivery-Id`, and the record becomes `status: "succeeded"`. To test a handler bug, configure the receiver to return `400`. The record becomes `status: "failed"` with no `nextAttemptAt`. Fix the receiver, call `POST /v1/integrator/webhooks/deliveries/{deliveryId}/redeliver`, and confirm the same `eventId` arrives once more and your inbox accepts it once. See [Webhooks](/guides/webhooks#retries-and-delivery-outcomes) for the full schedule. To test an unknown outcome, configure the receiver to delay its response for 10 seconds. Expect three signed attempts. The delivery record has `success: false`, a non-empty `error`, and no `responseStatus`. Do not treat that as proof the event was unprocessed; keep the handler safe to repeat. ### Prove push deduplication and ordering Register the receiver for all five `achDebit.*` communication events. Capture the `achDebit.authorized`, `achDebit.deliveryProgress`, and `achDebit.delivered` payloads from one `partial_then_full_delivery` lifecycle simulation. Then run the captured payloads through the same durable handler used in production: 1. Process the same `achDebit.deliveryProgress` payload twice. Assert one webhook-inbox row and one push-outbox row for its `eventId`. 2. Process `achDebit.delivered`, then process the earlier `achDebit.deliveryProgress` payload. Assert the lower `sequence` is recorded as stale and creates no push. 3. Process the same `achDebit.delivered` payload again after a simulated timeout. Assert its stable `eventId` still creates no second push. 4. Fail one semantic delivery, read its exact `payload` from `GET /v1/integrator/webhooks/deliveries`, and feed that payload into the handler. Assert it follows the same inbox and sequence rules. Do not implement this test by fetching the deposit and comparing its current status. The semantic payload, `eventId`, and `sequence` are the notification occurrence; API reads are only for current UI reconciliation. ### Reset a funding source `DELETE /v1/sandbox/funding-sources/{fundingSourceId}` removes a funding source so you can re-run a clean flow. A source disabled after a return cannot be linked again once removed. Use a fresh sandbox user for independent return scenarios, or link both deterministic account slots before testing repeated returns on one user. The `/v1/sandbox/bills/*` endpoints similarly simulate bill activation and verification challenges. See the [API Reference](/api-reference) for the full set of sandbox endpoints and their request shapes. ## Testing the off-ramp The sandbox covers the off-ramp API and the fiat side, with one deliberate exception worth planning around. ### There is no testnet — the chain leg is real mainnet We'll be straight with you about this one, because it affects how you plan your test cycle: the sandbox off-ramp mirrors production exactly, so it does not support testnets. To exercise a real off-ramp end to end, send a **small mainnet transaction with real stablecoins**. The simplest option is USDC on an EVM chain such as `base`. On `binance-smart-chain`, use USDT instead of USDC. Spritz recommends **\$0.01** per sandbox transaction. Yes, real money, even in sandbox. What you get for it is a full live flow with nothing stubbed, which is worth a great deal more than a mock right before you launch. If the lack of a testnet blocks your testing, [contact us](https://help.spritz.finance/en/). Two things take the sting out: * The crypto lands in a Spritz wallet. Tell us what you sent and we'll return it. * **No real fiat is paid out in sandbox.** You don't need a real bank account — add a dummy one. The routing number must be genuinely valid (we validate it, so use a real bank's), but the account number can be anything. Sandbox is the only place a made-up account number is the right answer. One more small-transaction nuance: sandbox pricing is not the published production pricing — always read the fee from the quote's `fees`/`input` fields rather than assuming the [rate card](/guides/pricing) applies to a test run. In **production**, quote creation is gated on live-money approval: until your production integrator is approved for live testing, `POST /v1/off-ramp-quotes` fails with `400` and detail `The integrator is not approved to move live money (sandbox_simulated_money_only)`. That gate does not apply to the sandbox environment. ### Where a sandbox off-ramp stops The crypto leg runs for real and the quote reaches `confirmed`. The fiat leg is simulated: the off-ramp is created and queues (`awaiting_funding` → `queued`), and `payment.created`/`payment.updated` webhooks fire — but no payout is submitted, so the record never reaches `in_flight` or `completed` and there is no `payment.completed` event in sandbox. Of the `offramp.*` milestone events, only `offramp.confirmed` can fire in sandbox. Test `offramp.inFlight`, `offramp.completed`, and the later states in production with a small live run. ### Terms acceptance is headless Where production asks the user to complete the provider's hosted terms flow, the sandbox accepts any opaque `agreementId`: ```bash theme={null} # Add body-specific HMAC headers and the user's Authorization as documented in Authentication. curl -X POST https://sandbox.spritz.finance/v1/users/me/terms \ -H "Content-Type: application/json" \ -d '{ "agreementId": "00000000-0000-0000-0000-000000000000" }' ``` The provider activates the customer asynchronously afterwards, so a small retry window applies before auto-ramp account creation succeeds — see [On-ramp](/guides/use-cases/on-ramp#before-you-start). ## Testing the on-ramp Simulate a fiat deposit arriving at an auto-ramp account — no real funds move, but the full conversion path runs, the on-ramp settles to `completed`, and real `onramp.*` webhooks fire: ```bash theme={null} # Add body-specific HMAC headers and the user's Authorization as documented in Authentication. curl -X POST https://sandbox.spritz.finance/v1/sandbox/auto-ramp-accounts/{id}/deposit \ -H "Content-Type: application/json" \ -d '{ "amount": "10.00" }' ``` The response carries the resulting `onRampId`, fees, and output; reconcile it through `GET /v1/on-ramps/{id}`. ## Going to production Point requests at `https://platform.spritz.finance`. Sandbox and production credentials are separate. Move your production integrator key and secret into your secrets manager. Live money movement is gated per integrator. If quote creation answers `400` with `sandbox_simulated_money_only` (or another live-money block reason), your production approval isn't finished — [talk to us](https://help.spritz.finance/en/). Remove any sandbox KYC bypass; real users complete [identity verification](/guides/quickstart) before moving money. # Security Source: https://docs.spritz.finance/guides/security How the Spritz platform protects your integration and your users' data. Security is built into how the Spritz API works. This page summarizes the controls that are in place and how they show up in your integration. ## Authenticated, signed requests Every request is authenticated. Backend calls are signed with HMAC-SHA256 using your integrator key, a timestamp, and a signature, so Spritz can confirm a request genuinely came from you and wasn't altered in transit. Timestamps are checked against a 5-minute window to reject replayed requests. See [Authentication](/guides/authentication). ## Scoped, per-user access You act on behalf of a specific user with their scoped user API key. For client-side flows, you mint a short-lived, per-user token on your backend and hand it to the user's device, so a browser or mobile app can call Spritz directly without ever holding your backend credentials. ## Sensitive card data stays out of your servers Full card numbers, CVVs, and PINs are handled through encrypted, secure components and rendered client-side, so that sensitive card data never passes through your servers. See [Cards](/guides/use-cases/cards). ## Signed webhooks Webhook deliveries are signed with a timestamped HMAC. You verify the signature against the raw request body before trusting a payload, so you can be certain an event genuinely came from Spritz, wasn't tampered with, and isn't an old request replayed. See [Webhooks](/guides/webhooks#verifying-signatures). ## Identity and monitoring Spritz runs identity verification (KYC), KYB, AML and sanctions screening, and transaction monitoring on the users moving money. Money-movement requests also pass risk checks before any funds move; a blocked ACH debit, for example, returns a `409` before the pull is initiated. See [Onboarding](/guides/onboarding) and [ACH returns](/guides/ach-returns). ## Encryption in transit All API traffic is served over HTTPS with TLS. ## Safe retries Idempotency keys ensure a retried request never moves money twice, even across network failures and timeouts. See [Idempotency](/guides/idempotency). ## Test safely Build and test against simulated rails using separate sandbox credentials before you touch real funds or real user data. See [Sandbox](/guides/sandbox). ## Compliance and due diligence For your security and compliance review, details on our compliance program, certifications, data handling, and banking and payment partners are available on request. Reach out and we'll share what's relevant to your review. # What we support Source: https://docs.spritz.finance/guides/supported Networks, tokens, settlement rails, and regions Spritz supports. This page summarizes what Spritz supports today: the blockchains and tokens users pay with, the rails and currencies money settles over, and the destination types you can pay out to. Coverage expands over time, so for programmatic checks query the live endpoints linked below rather than hardcoding these lists. ## At a glance | Capability | Direction | Rails | Regions and currencies | | - | - | - | - | | [Off-ramp](/guides/use-cases/off-ramp) | crypto to fiat | ACH, RTP, wire, EFT, SEPA, Faster Payments, push-to-card | US (USD), Canada (CAD), UK (GBP), EU (EUR) | | [On-ramp](/guides/use-cases/on-ramp) (auto-ramp accounts) | fiat to crypto | ACH, wire, SEPA | US (USD), EU (EUR) | | [Linked bank on-ramp](/guides/use-cases/linked-bank-onramp) | fiat to crypto | ACH debit | US (USD) | | [Bill pay](/guides/use-cases/bill-pay) | crypto to fiat | bill pay | US (USD) | | [Cards](/guides/use-cases/cards) | crypto-funded spend | card deposit | US (USD) | | [Embedded wallets](/guides/use-cases/embedded-wallets) | wallets and on-chain yield | on-chain | Multi-chain | ## Networks [Off-ramps](/guides/definitions#off-ramp) (crypto to fiat) support a broad set of chains: `ethereum`, `polygon`, `arbitrum`, `base`, `optimism`, `avalanche`, `binance-smart-chain`, `solana`, `bitcoin`, `tron`, `dash`, and `sui`. [On-ramps](/guides/definitions#on-ramp) (fiat to crypto) deliver to a focused subset: `ethereum`, `polygon`, `base`, `arbitrum`, `avalanche`, `optimism`, `solana`, `tron`, and `bitcoin`. ## Tokens Stablecoins are supported most broadly. USDC is available on nearly every network; USDT, DAI, USDP, and PYUSD are available on select networks. Off-ramps aren't limited to stablecoins. You can pay with other supported tokens by passing the token's contract address on the [off-ramp quote](/guides/use-cases/off-ramp). Different tokens have different fee tiers, and stablecoins like USDC are usually cheapest. ## Settlement rails and currencies Where fiat lands depends on the destination's region: | Region | Currency | Rails | | - | - | - | | United States | USD | ACH (standard and same-day), RTP, wire | | Canada | CAD | EFT | | United Kingdom | GBP | Faster Payments | | Eurozone | EUR | SEPA | Spritz also supports paying out to debit cards (`push_to_card`, see [Push to debit card](/guides/use-cases/push-to-debit)), to crypto cards (`card_deposit`), and to bills (`bill_pay`), in addition to bank accounts. ## Destination types You can pay out to several account types: * **Bank accounts**: US, Canadian, UK, and IBAN-based accounts * **Debit cards**: Visa and Mastercard * **Bills**: pay a credit card, loan, or other biller directly * **Virtual cards**: crypto-funded cards your users can spend ## Check coverage at runtime Coverage changes, so the API is the source of truth. Query it live instead of hardcoding: ```bash theme={null} # Supported rail / currency / network / token combinations curl https://platform.spritz.finance/v1/on-ramps/supported-pairs \ # plus your integrator signing headers (see Authentication) # Current exchange rates curl https://platform.spritz.finance/v1/on-ramps/exchange-rates ``` # Settlement timing Source: https://docs.spritz.finance/guides/timing How long each rail takes to settle. How quickly money arrives depends on the rail. The crypto leg confirms on-chain in the usual block times; the fiat leg is what varies. This page covers the timing you can expect for each rail Spritz supports. ## By rail | Rail | Speed | | - | - | | RTP (US) | Near-instant, around the clock, including weekends and holidays | | Faster Payments (UK) | Near-instant | | Wire (US) | Same banking day | | ACH same-day credit or payout | Same banking day when submitted before the cutoff | | ACH standard credit or payout | One to two banking days | | ACH debit on-ramp | Roughly three to five Federal Reserve banking days to settlement | | SEPA (EU) | About one banking day | | EFT (Canada) | One to three banking days | | Push to card | Minutes | ## Banking days and cutoffs ACH and wire settle on **banking days** only. A request made on a weekend or bank holiday, or after the day's cutoff, starts processing on the next banking day. Real-time rails like RTP and Faster Payments settle at any time, including weekends. Each rail has a daily cutoff, after which activity rolls to the next banking day. Choose the rail that matches how quickly the money needs to arrive: RTP or a wire when it has to be same-day, ACH when cost matters more than speed. ## On the on-ramp side A [linked bank on-ramp](/guides/use-cases/linked-bank-onramp) pulls funds by ACH debit. Spritz estimates settlement at roughly three to five Federal Reserve banking days, but does not currently expose a guaranteed delivery deadline. Normal-priority crypto is released after the debit settles. High priority, when enabled for your integration, can release some or all crypto earlier. Settlement is not finality: an ACH debit can still be returned later. See [ACH returns](/guides/ach-returns). ## Related Rails, currencies, and regions. How returned ACH debits work. # Bill pay with crypto Source: https://docs.spritz.finance/guides/use-cases/bill-pay Pay real-world bills directly with stablecoins. Bill pay lets your users pay real-world bills, like credit cards, loans, and utilities, directly with crypto. You link the user's bills once, then pay any of them by settling crypto over the `bill_pay` rail. ## Before you start * Authenticate as an integrator and act on behalf of a verified user. See [Authentication](/guides/authentication) and [Onboarding](/guides/onboarding). * Confirm the user's `bill_pay` capability is `active` (see [capabilities](/guides/onboarding#3-read-capabilities)). ## Link the user's bills Start activation. The `consent` object records the user accepting the bill pay terms. ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/bills/activate \ -H "Content-Type: application/json" \ # plus your integrator signing headers and the user's Authorization (see Authentication) -d '{ "consent": { "termsText": "...", "termsTextVersion": "2026-01-01", "acceptedAt": "2026-07-01T10:00:00.000Z" } }' ``` ```json theme={null} { "status": "verification_required", "activationId": "...", "verification": { "status": "challenge_required", "challenge": { "questions": [ { "id": "q1", "prompt": "Which of these is a past address?", "options": [ { "id": "o1", "text": "..." }, { "id": "o2", "text": "..." } ] } ] } } } ``` If `status` is `activated` or `already_active`, you're done and bills will sync. If it's `verification_required`, run the identity challenge: ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/bills/start_verification \ # plus signing headers and the user's Authorization -d '{ "activationId": "..." }' ``` Show the `challenge.questions` to the user (they're multiple choice), then submit their selections. ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/bills/submit_verification/{activationId} \ # plus signing headers and the user's Authorization -d '{ "responses": [ { "questionId": "q1", "value": "o2" } ] }' ``` ## List bills Once activated, the user's bills sync. List them to get balances and due dates: ```bash theme={null} curl "https://platform.spritz.finance/v1/bills/" \ # plus signing headers and the user's Authorization ``` ```json theme={null} [ { "id": "6a43ac369288351e982157b9", "status": "active", "name": "Chase Sapphire Card", "type": "credit_card", "accountNumberLast4": "4567", "currency": "USD", "liability": { "balance": "240.23", "amountDue": "28.34", "minimumPayment": "28.34", "nextPaymentDueDate": "2026-07-15" } } ] ``` A bill's `type` is one of `credit_card`, `auto_loan`, `loan`, `student_loan`, `mortgage`, `mobile_phone`, `utility`, or `unknown`. Only bills with `status: "active"` can be paid. ## Pay a bill Paying a bill uses the same quote-based flow as an [off-ramp](/guides/use-cases/off-ramp), with the bill as the destination and the `bill_pay` rail: ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/off-ramp-quotes/ \ -H "Content-Type: application/json" \ # plus signing headers and the user's Authorization -d '{ "accountId": "6a43ac369288351e982157b9", "amount": "28.34", "amountMode": "output", "rail": "bill_pay", "chain": "ethereum", "tokenAddress": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" }' ``` From there the flow is identical to an off-ramp: fetch the transaction parameters, have the user execute the payment on-chain, and track settlement. See the [off-ramp guide](/guides/use-cases/off-ramp) for those steps, and use the `payment.*` [webhooks](/guides/webhooks) to follow the bill payment to completion. ## Remove a bill ```bash theme={null} curl -X DELETE https://platform.spritz.finance/v1/bills/{billId} \ # plus signing headers and the user's Authorization ``` ## Related The quote-and-settle flow bill payments build on. Track bill payments with payment events. # Issue and fund a card Source: https://docs.spritz.finance/guides/use-cases/cards Issue and fund spend-anywhere cards backed by stablecoins. Spritz cards let your users spend their crypto anywhere cards are accepted. A card is issued automatically once the user's `crypto_card` capability is active. You fund it with crypto and manage it through the API. ## Before you start * Authenticate as an integrator and act on behalf of a verified user. See [Authentication](/guides/authentication) and [Onboarding](/guides/onboarding). * Confirm the user's `crypto_card` capability is `active`. Cards are provisioned automatically once it is, so there's no create call (see [capabilities](/guides/onboarding#3-read-capabilities)). ## Find the user's card ```bash theme={null} curl "https://platform.spritz.finance/v1/cards/" \ # plus your integrator signing headers and the user's Authorization (see Authentication) ``` ```json theme={null} [ { "id": "6a43ac369288351e982157b9", "status": "active", "type": "virtual", "country": "US", "currency": "USD", "last4": "4242", "limit": { "amount": "500.00", "interval": "daily" }, "payable": true, "renderSecret": "..." } ] ``` A card's reported `status` is `active`, `frozen`, `not_activated`, or `cancelled`, and its `type` is `physical` or `virtual`. Note that the values you *send* to change a card's state are slightly different (`active`, `locked`, `canceled`); see [Manage the card](#manage-the-card) below. ## Fund the card Load a card with crypto through the same quote-based flow as an [off-ramp](/guides/use-cases/off-ramp), with the card as the destination and the `card_deposit` rail: ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/off-ramp-quotes/ \ -H "Content-Type: application/json" \ # plus signing headers and the user's Authorization -d '{ "accountId": "6a43ac369288351e982157b9", "amount": "100.00", "amountMode": "output", "rail": "card_deposit", "chain": "ethereum", "tokenAddress": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" }' ``` Fetch the transaction parameters, have the user execute on-chain, and the funds land in their card program balance (see the [off-ramp guide](/guides/use-cases/off-ramp) for the on-chain steps). The program balance is a shared pool across the user's cards: ```bash theme={null} curl "https://platform.spritz.finance/v1/cards/balance" \ # plus signing headers and the user's Authorization ``` ## Manage the card ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/cards/{cardId}/update_status \ -d '{ "status": "locked" }' # active | locked | canceled ``` Use `locked` to freeze a card and `active` to unfreeze it. `canceled` is permanent. ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/cards/{cardId}/update_limit \ -d '{ "spendLimit": { "amount": "500.00", "interval": "daily" } }' ``` `interval` is `daily`, `weekly`, `monthly`, `yearly`, `all_time`, or `per_transaction`. ```bash theme={null} curl "https://platform.spritz.finance/v1/cards/{cardId}/transactions?limit=20" ``` Results are cursor-paginated and can be filtered by status and date range. Card PINs and full card details are handled with end-to-end encryption so sensitive data never touches your servers: set a PIN with `POST /v1/cards/{cardId}/update_pin`, retrieve the encrypted PIN with `POST /v1/cards/{cardId}/pin`, and render the full number and CVV using the card's `renderSecret` with Spritz's secure card-rendering component. ## Related The quote-and-settle flow card funding builds on. Activate the crypto\_card capability for a user. # Embedded wallets Source: https://docs.spritz.finance/guides/use-cases/embedded-wallets Give your users non-custodial wallets, backed by the same platform that powers Spritz. Spritz runs an embedded wallet platform that gives your users their own non-custodial wallets, without you having to build key management, signing, or on-chain infrastructure. It's the same wallet layer Spritz uses internally, and it pairs naturally with the ramps, cards, and bill pay in the rest of this documentation. ## What it can do * **Wallets for your users**: create and manage non-custodial wallets your users own. * **Balances and portfolio**: read token balances and portfolio snapshots across chains. * **Transactions**: prepare and submit on-chain transactions on the user's behalf. * **On-chain yield**: let users earn yield on their balances through vetted protocols. Because wallets sit alongside the rest of the platform, a user can hold a balance, earn yield on it, off-ramp part of it to their bank, and spend the rest on a card, all under one integration. ## Available for platform partners Embedded wallets are offered as part of a platform partnership rather than as a self-serve API today. If a wallet experience is part of what you're building, we'd love to hear about your use case and scope it with you. Tell us what you're building and we'll help you figure out the right setup. # Linked bank on-ramp Source: https://docs.spritz.finance/guides/use-cases/linked-bank-onramp Onboard users to crypto by linking their bank account and pulling funds via ACH debit. A linked bank on-ramp lets a user onboard to crypto straight from their bank account. They link the account once, authorize an ACH debit, and receive USDC at a wallet. No wallet signature is required; the authorization comes from the verified bank [funding source](/guides/definitions#funding-source). This is the canonical implementation guide. An integration is complete when its backend implements the flow and reconciliation below, and its frontend implements the [ACH debit user experience](/guides/ach-debit-user-experience). ## Before you build * Create or connect the user as described in [Onboarding](/guides/onboarding). * Confirm `GET /v1/integrator/` returns `achDebitEnabled: true`. If it is false, contact Spritz before building or testing ACH debit. * Wait until `GET /v1/users/me` shows a verified US user and the `fiat_to_crypto` / `ach_debit` capability as `active`. ACH debit has no separate terms requirement for an enabled integrator. * Keep the integrator key, integrator secret, and user API key on your backend. The short-lived `submissionToken` returned by deposit prepare is the only Spritz credential in this flow that you send to a browser or mobile app. * Let the client run Plaid Link, render authorization/UI, and submit the prepared deposit directly to Spritz. Your backend handles the other Spritz calls with HMAC and the user's authorization. * Capture the client's public IP at your authenticated edge. Do not ask the client to report its own IP or send it to a third-party IP lookup service. * Subscribe once per integrator to `onramp.*`, `achDebitReturn.*`, and `achDebit.*` webhooks. Store Spritz resource IDs so every event can be reconciled by API read. High priority is enabled only for reviewed integrations. Ask Spritz to enable it on your sandbox and production credentials. Use it only when `limitsByPriority.high.available` is `true`; normal priority is the complete default implementation. The flow keeps credentials, bank linking completion, limits, and preparation on your backend. The client handles Plaid Link, authorization UI, and the final scoped deposit submission: From your backend, create a Plaid link token for the user. ```bash theme={null} # Add body-specific HMAC headers and the user's Authorization as documented in Authentication. curl -X POST https://platform.spritz.finance/v1/bank-accounts/link-token \ -H "Content-Type: application/json" \ -d '{}' ``` ```json theme={null} { "linkToken": "link-sandbox-...", "hostedLinkUrl": "https://...", "expiration": "2026-06-30T12:40:00.000Z", "requestId": "..." } ``` Spritz requests Hosted Link for every link token. Choose exactly one completion mode from the returned values: * **Embedded/native Plaid Link:** initialize Plaid Link with `linkToken`. Your app receives the public token and selected account IDs, then your backend calls `link-complete` in the next step. * **Plaid Hosted Link:** when `hostedLinkUrl` is non-null, open it. Spritz receives Plaid's completion asynchronously and exchanges the public token for you. Do **not** call `link-complete` for this mode. If it is null, use embedded/native Link with `linkToken`. Plaid returns a public token and the selected account IDs to your client. Send them to your backend, then complete the link once: ```bash theme={null} # Add body-specific HMAC headers and the user's Authorization as documented in Authentication. curl -X POST https://platform.spritz.finance/v1/bank-accounts/link-complete \ -H "Content-Type: application/json" \ -d '{ "publicToken": "public-sandbox-...", "accountIds": ["..."], "institutionId": "ins_...", "institutionName": "..." }' ``` Before opening the URL, save the user's current funding-source IDs and allow only one active Hosted Link session for that user. After the user returns, poll `GET /v1/funding-sources/` every 2 seconds for up to 60 seconds; an `account.created` event should trigger the same read immediately. Linking is complete when a previously unseen source appears as `active`, `review_required`, or `ineligible`; each state has a UI action below. Stop polling when it appears. If 60 seconds expires, say “We’re still linking your bank account” and offer **Check again** or a fresh Link session. This timeout is a UI recovery bound, not proof that linking failed; handle a later source or event idempotently. Never ask the client for a public token and never call `link-complete` in this branch. Linking creates a funding source. Wait for one with `status: "active"`, then read its limits. ```bash theme={null} # Add HMAC headers and the user's Authorization as documented in Authentication. curl "https://platform.spritz.finance/v1/funding-sources/" ``` A funding source moves through `pending`, `active`, `review_required`, `ineligible`, `disabled`, or `deleted`. Only `active` sources can be debited. Check what the user can deposit: ```bash theme={null} # Add HMAC headers and the user's Authorization as documented in Authentication. curl "https://platform.spritz.finance/v1/funding-sources/{fundingSourceId}/deposit-limits" ``` ```json theme={null} { "limitsByPriority": { "normal": { "available": true, "minAmountUsd": "10.00", "maxAmountUsd": "742.57", "reason": null, "suggestedAction": null, "clearsAt": null, "clearsAtIsEstimate": false }, "high": { "available": false, "maxAmountUsd": "0.00", "maxEarlyReleaseAmountUsd": "0.00", "reason": "not_available", "suggestedAction": null, "clearsAt": null, "clearsAtIsEstimate": false } } } ``` Present two delivery choices, not three limits: * **Standard:** send `priority: "normal"` and validate the amount with `normal.minAmountUsd` and `normal.maxAmountUsd`. * **Fastest available:** offer this only when `high.available` is true, send `priority: "high"`, and validate the amount with `high.maxAmountUsd`. Do not show both maximums at once or label `high.maxAmountUsd` as an “instant limit.” It is the fee-adjusted maximum deposit for the Fastest available choice. `high.maxEarlyReleaseAmountUsd` is only the largest portion that may release before settlement; the user does not allocate that portion. The prepare response supplies the exact early/later split to display before authorization. With `quoteType: "exact_output"`, each `maxAmountUsd` is the USDC principal the user can receive, excluding fees. Spritz has already reduced it enough for the principal plus fee to fit the applicable bank-debit limit. For example, with a `$750.00` total-debit ceiling and no plan adjustment or subsidy: * Standard: `$742.57` principal + `$7.43` fee = `$750.00` bank debit. * Fastest available with `$100.00` eligible for early release: `$100.00` at 2% plus `$641.58` at 1% produces an `$8.42` fee, so `$741.58` principal + `$8.42` \= `$750.00` bank debit. `maxAmountUsd` is a principal ceiling for both quote types. With `quoteType: "exact_input"`, request `amountUsd` is instead the maximum total bank debit, including fees, so do not compare it directly with `maxAmountUsd`. Send the user's total-debit budget to prepare; Spritz chooses the largest principal that fits it and returns the authoritative `principalAmountUsd`, `userFeeUsd`, and `totalDebitAmountUsd` in `summary`. Use `exact_output` when your amount picker needs to validate the requested principal directly against `maxAmountUsd`. When `available` is false, branch on `reason`. `suggestedAction` is `auto_ramp`, `wait_for_settlement`, or `null`. `clearsAt` is the earliest time the current reason can stop applying; `clearsAtIsEstimate: true` means it is only a forecast. Re-fetch before prepare and again after a blocked attempt. Never hardcode limits or infer the policy that produced them. Prepare a quote. This returns the ACH authorization text to show the user, along with a `preparationId` you'll use to commit. ```bash theme={null} # Add body-specific HMAC headers and the user's Authorization as documented in Authentication. curl -X POST https://platform.spritz.finance/v1/deposits/direct/prepare \ -H "Content-Type: application/json" \ -d '{ "sourceId": "{fundingSourceId}", "address": "YourUsersSolanaWallet", "network": "solana", "asset": "USDC", "quoteType": "exact_output", "amountUsd": "500.00", "priority": "normal", "clientNetwork": { "ipAddresses": ["1.1.1.1"] } }' ``` ```json theme={null} { "preparationId": "...", "kind": "deposit_authorization", "expiresAt": "2026-06-30T12:40:00.000Z", "message": "ACH authorization text to display to the user", "submissionToken": "ach_submit_...", "summary": { "requestedPriority": "normal", "priority": "normal", "releaseDecisionMode": "after_settlement", "principalAmountUsd": "500.00", "instantPortionUsd": "0.00", "settlementPortionUsd": "500.00", "regularPublishedFeeUsd": "5.00", "instantPublishedFeeUsd": "0.00", "publishedFeeUsd": "5.00", "totalDebitAmountUsd": "505.00" } } ``` `quoteType` is `exact_input` (you specify the USD debited) or `exact_output` (you specify the crypto delivered). `network` supports `solana`, `ethereum`, `polygon`, `base`, `avalanche`, and `arbitrum`; `asset` is `USDC`. Set `clientNetwork.ipAddresses` from the public source address your edge observed for this authenticated client session. Send one address, or at most one IPv4 and one IPv6 address when your edge observed both. Private, loopback, reserved, and backend addresses are rejected. The returned `submissionToken` is short-lived and can create only this preparation for this user and integration. Map **Standard** to `priority: "normal"` and **Fastest available** to `priority: "high"`. For Fastest available, Spritz decides the split: `instantPortionUsd` may release before settlement and `settlementPortionUsd` waits for settlement. Show those two amounts and timings from this response. Do not ask the user to choose the portions themselves. Display the quote `summary` and the ACH authorization `message` to the user. Once they authorize, send `submissionToken` and `preparationId` to the client. The client calls Spritz directly from the same network your edge observed during prepare: ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/deposits/direct \ -H "Content-Type: application/json" \ -H "Authorization: Bearer ach_submit_..." \ -H "Idempotency-Key: your-unique-deposit-intent-key" \ -d '{ "preparationId": "..." }' ``` Do not route this request through your backend. Spritz observes the request IP and accepts it only when it matches a client IP supplied during prepare and differs from the backend IP that prepared the deposit. The capability cannot read or modify any other resource. You can temporarily keep the existing HMAC-authenticated backend flow. Omit `clientNetwork` from prepare; the response will not include a `submissionToken`. On create, add the edge-observed public `clientIp` to the body **before** calculating the HMAC signature: ```bash theme={null} # Add body-specific HMAC headers and the user's Authorization as documented in Authentication. curl -X POST https://platform.spritz.finance/v1/deposits/direct \ -H "Content-Type: application/json" \ -H "Idempotency-Key: your-unique-deposit-intent-key" \ -d '{ "preparationId": "...", "clientIp": "1.1.1.1" }' ``` `clientIp` is required, must be public, and must differ from the backend IP Spritz observes on create. An integrator JWT (`spr_...`) cannot use this backend migration path because it does not sign the request body. Spritz rechecks the source, amount, and effective release availability before any money moves. A blocking check creates no deposit. A create attempt consumes its `preparationId`; prepare a new authorization for a new attempt. Reuse the same `Idempotency-Key` and body only to recover from a timeout or lost response. A high-priority request can instead be reduced or downgraded without failing. The create response then preserves `requestedPriority: "high"` while `priority`, release portions, timing, and fees describe what will actually happen. Render the create response again before showing confirmation; do not rely on the earlier preparation summary. The deposit response carries the full lifecycle. Track it by reading the deposit or by reacting to [webhooks](/guides/webhooks). ```json theme={null} { "id": "...", "status": "authorized", "requestedPriority": "normal", "priority": "normal", "releaseDecisionMode": "after_settlement", "principalAmountUsd": "500.00", "instantPortionUsd": "0.00", "settlementPortionUsd": "500.00", "expectedAssetAmount": "500.00", "network": "solana", "asset": "USDC", "address": "YourUsersSolanaWallet", "debitStatus": "authorized", "releaseStatus": "not_started" } ``` `status` is `authorized`, `processing`, `partially_released`, `completed`, `failed`, `refunded`, or `returned`. The ACH pull is tracked independently in `debitStatus` (`authorized`, `submitting`, `submitted`, `settled`, `returned`, `failed`). Crypto is tracked in `releaseStatus` (`not_started`, `queued`, `partial`, `completed`, `failed`). `releasedAmountUsd` includes submitted releases. Tell the user crypto was delivered only as `confirmedReleasedAmountUsd` increases. Use `payoutTxHash` as blockchain proof when present. For `early_partial`, the instant portion can confirm while the settlement portion remains pending. A settled ACH debit can later become `returned`, even when the aggregate deposit previously showed `completed`, `failed`, or `refunded` because of its crypto-release path. Keep processing deposit and `achDebitReturn.*` webhook updates. Use `debitStatus` and `releaseStatus` to explain intermediate states. Reconcile after an outage by paging through `GET /v1/deposits/`. The list is user-scoped, so an integrator-wide recovery must iterate your own user roster and authorize each user's read. Webhooks are notifications, not the only record of deposits. ## When linking is blocked `link-complete` can return a `409` after Plaid succeeds. Branch on `code`; retrying the same account cannot clear these conflicts. | `code` | Meaning | What your UI should do | | - | - | - | | `PLAID_ACCOUNT_ALREADY_LINKED` | The user already has this usable bank account. No duplicate source was created. | Close Link, re-list funding sources, and select the existing source. | | `PLAID_ACCOUNT_BLOCKED` | This account was permanently disabled by a return, risk decision, or operator action. Relinking cannot bypass it. | Offer another bank and your standard support path. | | `PLAID_ACCOUNT_REROUTED` | A time-boxed bank-risk decision still applies. Relinking does not shorten it. | Re-list funding sources and use the existing source's structured `availableAt`. Offer another bank when no source is visible. Never parse a date from `detail`. | | `PLAID_ACCOUNT_LINK_INCOMPLETE` | An older link exists without a usable funding source. | Stop retrying and send the user to support. | | `PLAID_ACCOUNT_LIMIT_EXCEEDED` | The user already has the maximum number of linked accounts. | Close Link and let the user choose an existing account. | | `PLAID_DUPLICATE_IDENTITY` | This verified person already belongs to another Spritz user. | Stop linking. Tell the user to use their existing account or contact support. Do not retry. | | `PLAID_DUPLICATE_BANK_ACCOUNT` | This bank account already belongs to another Spritz user. | Stop linking. Tell the user to use the account where the bank is already linked or contact support. Do not retry. | | `PLAID_ACCOUNT_HOLDER_UNAVAILABLE` | The linked account has no usable holder identity to compare. | Stop retrying this account. Offer another bank or support. | | `PLAID_VERIFIED_IDENTITY_UNAVAILABLE` | The verified user record lacks the identity data required for ownership matching. | Stop retrying Link and send the user to support. | A `503` with `PLAID_LINK_FAILED`, `PLAID_SANDBOX_LINK_UNAVAILABLE`, or `PLAID_IDENTITY_UNAVAILABLE` is different: the provider flow did not complete. Keep the user on linking and allow a bounded retry or a different bank. If a user deliberately unlinks a healthy account, its funding source becomes `deleted`. Relinking the same account restores the original bank-account and funding-source IDs to `active`; update the existing local payment method instead of inserting a duplicate. Relinking does not clear a return or risk block: those attempts follow the blocked rows above. ## When a deposit is blocked Spritz runs every deposit through eligibility, limit, and risk checks. A block returns an [error](/guides/errors) **before any money moves** — no ACH pull is attempted. Most deposit failures collapse onto a few problem `type`/`status` values, so branch on the `code` extension when it is present. A code-less `409` can occur when another create request for the same user is still in progress. Do not assume whether the first request succeeded. Wait briefly, reconcile with the same `Idempotency-Key` and `GET /v1/deposits/`, then retry the same body with that key at most once. Do not create a new deposit intent until reconciliation is clear. Both `prepare` and `create` can return: | `code` | Status | Meaning | What to do | | - | - | - | - | | `validation_error` | `400` | A request field is malformed or unsupported. | Fix the named `field`. | | `minimum_deposit` | `400` | `amountUsd` is below the current inclusive minimum. | Re-read limits, keep the user on the amount screen, and show `minAmountUsd`. | | `transaction_limit` | `400` | `amountUsd` is above the current inclusive maximum. | Re-read limits, keep the user on the amount screen, and show `maxAmountUsd`. | | `daily_limit`, `monthly_limit` | `400` | A legacy period limit blocked the amount. | Re-read `limitsByPriority` and use `clearsAt` when available. | | `unsettled_deposit_limit` | `400` | Too many ACH debits are still unsettled. | Wait for settlement and use `clearsAt` when available. | | `unsettled_amount_limit` | `400` | The amount would exceed the user's in-flight debit capacity. | Re-read limits; wait for settlement. | | `bank_unsettled_deposit_limit` | `400` | A legacy bank-specific in-flight limit blocked the request. | Wait for settlement or offer another bank. | | `open_exposure`, `aggregate_exposure` | `400` | The amount exceeds current user or program exposure capacity. | Re-read limits and follow `suggestedAction` / `clearsAt`. | | `new_user_admission_paused`, `rail_halted` | `400` | ACH debit is temporarily unavailable for this request. | Follow `suggestedAction`; offer another supported on-ramp or retry later. | | `source_not_found` | `404` | The `sourceId` doesn't exist or isn't this user's. | Re-list funding sources. | | `source_rerouted` | `409` | The funding source is temporarily unavailable after a bank-risk check (`statusReason: "rerouted"`). The body carries `availableAt`. | Show "available again on `availableAt`"; relinking the same account does not shorten it. Offer a different bank account. | | `source_disabled` | `409` | The funding source is permanently unavailable (`status: "disabled"`). The body carries `permanent: true`; private disablement inputs are not returned. | Offer a different bank account. | | `source_review_required` | `409` | Ownership of the funding source is still under review. | Wait; the user may be contacted. | | `source_pending` | `409` | The funding source is still being verified. | Retry once it is `active`. | | `source_not_eligible` | `409` | The funding source isn't `active` for another reason (e.g. `ownership_mismatch`). | Read its `status` and `statusReason` (see [Funding source status](#funding-source-status)), then choose another account. | | `ach_debit_program_halted` | `409` | ACH debit is paused for your integration. | Do not loop. Show temporary unavailability and retry later. | | `ach_debit_new_user_paused` | `409` | ACH debit is temporarily unavailable for this user. | Do not expose user classification. Offer another supported on-ramp or retry later. | | `ach_debit_user_access_disabled` | `409` | This user's ACH debit access is disabled, usually after a return. | Not reinstated automatically; contact Spritz. | | `ach_debit_user_access_restricted` | `409` | New ACH debits are paused while Spritz reviews ACH access. Existing deposits continue to settle. | Stop retrying and show the support/review path. | | `ach_debit_source_identity_ambiguous` | `409` | The bank identity maps to more than one active funding source. | Remove the duplicate source. | | `ach_debit_source_unsettled_limit` | `409` | The source already has an unsettled debit in flight. | Wait for it to settle, then retry. | Integration-level errors can also block prepare or create: | `code` | Status | What to do | | - | - | - | | `integration_unavailable` | `503` | Temporary integration configuration failure. Log it, show a generic temporary error, and contact Spritz if it persists. | | `integration_not_approved` | `403` | The integration is not approved for this live-money action. Contact Spritz. | | `integration_amount_limit` | `422` | The amount exceeds the integration's current transaction allowance. Show the current maximum from deposit limits. | | `integration_usage_limit` | `422` | The integration's current usage allowance is exhausted. Stop retries and contact Spritz. | | `integration_state_conflict` | `404` or `409` | Spritz could not reconcile this attempt's current state. Reconcile by idempotency key and deposit reads before retrying. | | `client_network_required` | `400` or `403` | The client IP is missing, non-public, or unavailable to the direct request. Fix edge IP capture or restore direct client connectivity; do not substitute the backend IP. | | `client_network_unavailable` | `503` | Spritz could not verify the backend request IP. Retry after restoring the normal network path; contact Spritz if it persists. | | `client_network_mismatch` | `403` | The direct submission did not arrive from a client IP supplied during prepare. Prepare again from the client's current session and submit directly. | | `client_submission_required` | `403` | The request came from the preparing/submitting backend, or used an unsupported integrator credential. Use the direct `submissionToken` flow, or HMAC plus a distinct signed `clientIp` during migration. | `create` can additionally return: | `code` | Status | Meaning | | - | - | - | | `preparation_not_found` | `404` | Unknown `preparationId`. | | `preparation_expired` | `409` | The preparation passed its `expiresAt`. Prepare a new quote. | | `preparation_already_used` | `409` | The preparation was already committed. A blocked attempt also consumes it, so prepare a new quote to retry. | ### Deposit decisions At `create`, Spritz evaluates the deposit for ACH-return risk. These blocking outcomes return `409` and **do not create the deposit**: | `code` | Meaning | What to do | | - | - | - | | `risk_review_required` | The deposit needs review. | Show “This deposit needs review.” Do not retry automatically. | | `risk_rejected` | Spritz declined the deposit. | Show “This bank deposit is unavailable.” Do not vary the amount or retry in a loop. | | `risk_rerouted` | Spritz declined this attempt. Despite the name, no alternate route was created. | Refresh the funding source. Offer another active bank or wait until `availableAt`; do not probe with different amounts. | | `risk_evaluation_unavailable` | A required decision could not be completed. | Prepare a new authorization and make one bounded retry later. | A decision can also change the funding source. Always re-read it after a blocked create. `disabled` / `permanent: true` requires another bank or support. `ineligible` / `rerouted` can recover at `availableAt`. Relinking the same account does not bypass either state. Do not show the raw risk code or reason to the user. ### Instant release adjustments High priority is a request, not a guarantee. The create response can reduce the instant portion or set `priority: "normal"` without failing the deposit. The rest is released after ACH settlement. Do not infer or expose the control that produced the adjustment; render the returned result: | Field | How to use it | | - | - | | `requestedPriority` | What the user asked for. | | `priority` | The effective pricing and release priority. | | `releaseDecisionMode` | `early_full`, `early_partial`, or `after_settlement`. | | `instantPortionUsd` | Principal released before settlement. | | `settlementPortionUsd` | Principal released only after settlement. | | `regularPublishedFeeUsd` | Fee for the settlement portion. | | `instantPublishedFeeUsd` | Fee for the instant portion. | | `publishedFeeUsd` | Their sum before final adjustments and subsidy. | Always render timing, portions, `userFeeUsd`, and `totalDebitAmountUsd` from the create response. A downgrade is a successful deposit, not an error. Continue tracking it and show standard settlement timing; do not retry solely to recover instant release. ## Funding source status A funding source moves through these states, and only `active` can be debited. When it isn't active, `statusReason` explains why. | `status` | Debitable | Typical `statusReason` | | - | - | - | | `pending` | No | `null` — ownership and verification still resolving | | `active` | Yes | `null` | | `review_required` | No | `ownership_review_required` | | `ineligible` | No | `ownership_mismatch`, or `rerouted` (time-boxed — see `availableAt`) | | `disabled` | No | `risk_blocked`, `returned`, or `manually_disabled` (`permanent: true`) | | `deleted` | No | The user unlinked this source; `deletedAt` records when | `statusReason` takes one of: | Value | Meaning | | - | - | | `ownership_mismatch` | The account holder's name didn't match the verified Spritz user. | | `ownership_review_required` | Ownership couldn't be confirmed automatically and needs review. | | `user_not_verified` | The user isn't verified for ACH debit. | | `duplicate_bank_account` | The same bank account is already linked. | | `returned` | A prior debit on this source or user was returned. | | `risk_blocked` | Spritz made the source permanently unavailable. Show another bank or support, not the raw reason. | | `rerouted` | Spritz made the source temporarily unavailable. It can be usable again at `availableAt`. | | `manually_disabled` | An operator disabled the source. | Two further fields say what happens next: | Field | Meaning | | - | - | | `availableAt` | For a time-boxed block (`statusReason: "rerouted"`), when the source becomes usable again on its own. `null` otherwise. | | `permanent` | `true` when the source is `disabled` and will not recover on its own — offer a different bank account. | ## Handling returns ACH debits can be returned after the fact (for example, insufficient funds). When that happens, the deposit reports a `returnCode` and `returnReason`, the funding source becomes permanently disabled, and the `achDebitReturn.created` and `achDebitReturn.updated` [webhooks](/guides/webhooks) fire. A cached funding-source read can briefly lag the returned deposit, so use either return webhook as the trigger for bounded `GET /v1/funding-sources/:sourceId` polling. Branch on the return record's `userAction`: `none` can offer another bank, while `review_required`, `restricted`, and `disabled` must show the support path and stop new ACH debit attempts. See [ACH returns](/guides/ach-returns) for the exact frontend contract and [Sandbox](/guides/sandbox#simulate-ach-returns) for deterministic return scenarios. ## Related Implement every UI, email, and push state. Run deterministic scenarios and the documented contract-only cases. Auto-converting deposit accounts for push funding. Track deposit status and ACH returns. ## Definition of complete * [ ] Backend creates/connects users, waits for active ACH debit capability, and keeps all secrets server-side. * [ ] Embedded Link calls `link-complete`; Hosted Link waits for Spritz's asynchronous completion. Both store stable bank/source IDs. * [ ] Frontend handles every funding-source status and every link conflict above. * [ ] Amount entry is driven by fresh `limitsByPriority`; no limits are hardcoded. * [ ] Authorization displays the exact server-provided message and create-response summary. * [ ] Prepare sends the public client IP observed by the authenticated edge, and the client submits the returned capability directly to Spritz from that network. * [ ] Any temporary backend-create fallback uses HMAC and signs the required `clientIp` as part of the request body. * [ ] Deposit creation uses a persisted `Idempotency-Key`. * [ ] Normal, partial-instant, full-instant, and high-to-normal results render correctly when enabled. * [ ] Bank debit and crypto delivery are tracked as separate state machines. * [ ] Resource webhook handlers verify signatures, fetch current state, and reconcile after outages; push handlers atomically deduplicate `achDebit.*` by `eventId` and reject stale `sequence` values. * [ ] Returns disable the source in the UI and branch on `userAction`. * [ ] Every scenario in the [sandbox test matrix](/guides/sandbox#ach-debit-release-checklist) passes before production. # Off-ramp: crypto to bank Source: https://docs.spritz.finance/guides/use-cases/off-ramp Convert stablecoins and other tokens to fiat and settle to a user's bank account. An [off-ramp](/guides/definitions#off-ramp) converts crypto to fiat and settles it to a user's destination account, such as a [bank account](/guides/definitions#bank-account). This guide walks the US flow end to end — USDC on Base to a US bank account over ACH or RTP — and every status, error, and edge you'll meet along the way. ## The mental model: two resources, two lifecycles An off-ramp is two legs, and the API models them as two resources: 1. The **off-ramp quote** (`GET /v1/off-ramp-quotes/{id}`) covers the **crypto leg** — the price, the fees, and the user's on-chain payment. 2. The **off-ramp** (`GET /v1/off-ramps/{id}`) covers the **fiat leg** — the payout to the destination account, created once the crypto payment is confirmed on-chain. Each has its own status enum, and they move independently. Don't collapse them into one status field in your database — a quote can be `completed` while its off-ramp is still `in_flight`, and refunds live on the off-ramp, not the quote. Both lifecycles are spelled out below. ## Before you start * Authenticate as an integrator acting for a user. See [Authentication](/guides/authentication). * The user is verified and their `crypto_to_fiat` capability is `active`. Drive this from the capabilities array on `GET /v1/users/me`, not from KYC status alone — capabilities tell you exactly what's blocking a product and why. See [Onboarding](/guides/onboarding#3-read-capabilities). * Decide how the user pays (below). ## Decide how the user pays There are two ways to fund an off-ramp, and the choice shapes your UX: **Signed transaction (recommended).** You create a quote, fetch ready-to-sign transaction parameters, and the user's wallet signs and submits the payment on-chain. Spritz sees the transaction the moment it confirms and starts the fiat payout immediately. Use this when your product can produce a signed on-chain transaction — it's what the overwhelming majority of Spritz's own app users do, and it's the rest of this guide. **Auto-ramp addresses.** Every payable account gets a unique deposit address (the same address on every EVM chain), and any crypto sent to it auto-converts to that account. Spritz creates these addresses automatically; there is no create call. List them with `GET /v1/auto-ramp-addresses?accountId={accountId}`. Listing addresses creates no resource and no transaction. An off-ramp record appears only after a deposit to the address is detected. The trade-offs: no pre-committed quote (fees are computed on arrival, not agreed up front), no instant feedback (you learn from a `payment.created` webhook or by polling `GET /v1/off-ramps`), and slower settlement, because the deposit must be detected before processing starts. Use this when you can't sign transactions — or as a secondary "send from anywhere" option alongside the primary flow. ## Step 1 — Add the bank account Paying out to the user's debit card instead? Add it with `POST /v1/debit-cards` — card entry runs in an Evervault iframe so card numbers never reach your servers — then continue from step 2 with `rail: "push_to_card"`. See [Push to debit card](/guides/use-cases/push-to-debit). ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/bank-accounts \ -H "Content-Type: application/json" \ # plus your integrator signing headers and the user's Authorization (see Authentication) -d '{ "type": "us", "ownership": "personal", "routingNumber": "021000021", "accountNumber": "123456789", "accountSubtype": "checking" }' ``` The account comes back `active` with the rails it supports: ```json theme={null} { "id": "6a43ac369288351e982157b9", "status": "active", "statusReason": null, "accountHolderName": "Jane M Doe", "supportedRails": ["ach_standard", "rtp"], "label": "Bank Account •••• 6789", "createdAt": "2026-09-03T15:47:51.729Z", "fundingSourceId": null, "type": "us", "currency": "USD", "accountNumberLast4": "6789", "routingNumberLast4": "0021", "accountSubtype": "checking" } ``` `institution` (`{ name, logo }`) appears once the institution resolves — treat it as optional enrichment, and render the `label` when it's absent. **Routing numbers are validated; account numbers cannot be.** No upfront check exists for an account number — a typo'd one is accepted by the API, looks healthy, and is only rejected by the bank at payout time, coming back as an [ACH return](/guides/ach-returns). Mobile users thumb-typing ten digits off a photo of a cheque are the common case. Build a confirmation step: after entry, show the number back with the last four digits hidden (`12345••••`) and make the user re-enter those four from their statement or cheque. Block paste on that field so it's a genuine second read of the source, and hide the digits you're asking them to reproduce — showing the last four and then asking for the last four only tests whether they can read your screen. A full double-entry of the whole number is stronger still, at more friction. This small piece of UI kills most of this error class, and it matters: returns are a normal part of ACH that Spritz handles, but a sustained high return rate is something we'll ask you to fix. List accounts with `GET /v1/bank-accounts` (a bare array, not a paginated envelope) and read per-account limits with `GET /v1/bank-accounts/{id}/payment-limits`: ```json theme={null} { "transactionLimit": "20000.00", "dailyLimit": "150000.00", "dailyRemaining": "150000.00" } ``` `dailyRemaining` is shared across the user's accounts, not per account. All three are decimal strings. Remove an account with `DELETE /v1/bank-accounts/{id}`; later reads of it return `404`. ### Destination eligibility `status` on `GET /v1/bank-accounts` or `GET /v1/bank-accounts/{accountId}` is the only eligibility signal for a payout destination. `active` is payable. Any other value is unpayable, and `statusReason` says why. All current reasons are terminal, so prompt the user to add a different account. Treat an unknown `status` as unpayable. Payout bank accounts have no ownership field. `accountHolderName` is a display value, not an ownership-match or eligibility decision, so do not branch on it. Ownership decisions apply to [linked-bank funding sources](/guides/use-cases/linked-bank-onramp#funding-source-status) for ACH debit, and appear there as the funding source's `status` and `statusReason`. ## Step 2 — Create the quote ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/off-ramp-quotes \ -H "Content-Type: application/json" \ # plus your integrator signing headers and the user's Authorization -d '{ "accountId": "6a43ac369288351e982157b9", "amount": "100.00", "amountMode": "output", "rail": "ach_standard", "chain": "base", "tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" }' ``` ```json theme={null} { "id": "6a99…", "fulfillment": "sign_transaction", "status": "created", "createdAt": "2026-09-03T16:00:00.000Z", "input": { "amount": "102.00", "currency": "USD", "tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "chain": "base" }, "output": { "amount": "100.00", "currency": "USD", "rail": "ach_standard", "accountId": "6a43ac369288351e982157b9", "estimated": false, "exchangeRate": null }, "fees": { "amount": "2.00", "currency": "USD" }, "sendTo": null, "confirmation": null, "offRampId": null } ``` The fields that bite people: * **`amountMode` decides what `amount` means.** `output` (the default) means the destination receives exactly that amount, fees on top — `input.amount` is what the user pays in total. `input` means the user sends exactly that USD value and the destination receives what's left after fees. Choose deliberately; this is the number-one source of "why is the amount different" tickets. EUR destinations require `input`, and their `output` is an estimate (`output.estimated: true`); the settled amount is reported by the off-ramp resource. * **`tokenAddress` is required on every chain except Bitcoin, Dash, and XRP.** There is no native-token fallback — omitting it is rejected with a `400`. Different tokens carry different [fee tiers](/guides/pricing), and USDC on a low-cost network (Base, Polygon, Arbitrum, Optimism, Avalanche, Solana) is the cheapest way to pay. * **`amount` is always a decimal string** — `"100.00"`, never `100`. In `input` mode the amount must exceed the fees, or creation fails with `400` — note the detail text ("Amount must be greater than zero") blames the input, not the fee: if you see it on a positive amount, your amount is at or below the fee floor. * **`rail`** picks the fiat settlement rail. For US bank accounts: `ach_standard` (one to two banking days, cheapest), `ach_same_day` (same banking day before the cutoff), and `rtp` (near-instant, around the clock including weekends and holidays, costs more). See [Settlement timing](/guides/timing) and [End-user pricing](/guides/pricing). Pick a rail the destination's `supportedRails` lists. ### Read `fulfillment` before you go further `fulfillment` tells you how the quote gets paid: * `sign_transaction` — call `POST /v1/off-ramp-quotes/{id}/transaction` for parameters, sign, and submit on-chain (step 3). This is what you'll get on EVM chains and Solana. * `send_to_address` — send exactly `sendTo.amount` of `sendTo.token` to `sendTo.address` before `sendTo.expiresAt` (Bitcoin, Dash, Tron). No transaction parameters exist for these quotes; calling the transaction endpoint returns a `422`. Quotes are time-bound. For `send_to_address` the deadline is explicit (`sendTo.expiresAt`); for `sign_transaction`, fetch parameters, sign, and submit promptly rather than holding a quote across a long session. An expired quote can't be fulfilled — create a new one. ## Step 3 — Get parameters, approve, sign, submit ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/off-ramp-quotes/{quoteId}/transaction \ -H "Content-Type: application/json" \ # plus your integrator signing headers and the user's Authorization -d '{ "senderAddress": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18" }' ``` `senderAddress` is the wallet that will send the payment. It's optional on EVM chains and **required for Solana** (which also accepts an optional `feePayer`). On EVM you get back everything needed to build the transaction: | Field | Use | | - | - | | `contractAddress` | The `to` of the transaction | | `calldata` | The `data` | | `method` | The contract method, e.g. `payWithToken` | | `value` | Native value in wei — `null` for ERC-20 payments | | `requiredTokenInput` | Exact `inputToken` amount in smallest units (USDC has 6 decimals, so `"100000000"` is 100 USDC) | | `inputToken` / `outputToken` | Token contract addresses (same for direct payments; different on swap paths) | | `chain` | The chain to submit on | On Solana you get a base64 `transactionSerialized` ready to sign and submit to `recipientAddress`. **Approve the allowance first.** The payment contract pulls tokens out of the user's wallet, so the wallet must have approved `contractAddress` to spend at least `requiredTokenInput` of `inputToken` *before* the payment transaction will succeed. Treat it as a standard approve-then-execute pair. Forgetting the approve leg is the single most common reason a first integration reverts — if your transaction is failing for no apparent reason, check the allowance before anything else. ## Step 4 — Track both lifecycles Once the transaction is on-chain, watch the quote for the crypto leg and the off-ramp for the fiat leg. After the payment is detected, the quote's `confirmation` carries the `transactionHash` and an `explorerUrl` you can link the user to. The off-ramp resource itself: ```json theme={null} { "id": "6a99…", "quoteId": "6a99…", "status": "in_flight", "createdAt": "2026-09-03T16:00:20.000Z", "completedAt": null, "input": { "amount": "102.00", "token": "USDC", "chain": "base" }, "output": { "amount": "100.00", "currency": "USD", "accountId": "6a43ac369288351e982157b9", "accountName": "Chase •••• 6789", "rail": "ach_standard" }, "fiatDestination": "bank_account", "fees": { "amount": "2.00", "currency": "USD" }, "transaction": { "hash": "0xabc123…", "explorerUrl": "https://basescan.org/tx/0xabc123…" } } ``` ### Correlating a quote to its off-ramp The two resources link by id, in both directions: * The quote's `offRampId` (null until the fiat leg exists) — poll the quote, or take the off-ramp id from the `payment.created` webhook and confirm it matches. * The off-ramp's `quoteId` (null for auto-ramp-address deposits, which have no quote). List off-ramps with `GET /v1/off-ramps?accountId={accountId}` (cursor-paginated — `{ data, hasMore, nextCursor }`, newest first, *not* a bare array like `GET /v1/bank-accounts`). ### Quote statuses (crypto leg) | `status` | Meaning | What to do | | - | - | - | | `created` | Quote exists, no transaction seen yet | Wait for the user to submit | | `transaction_pending` | The payment transaction is seen, awaiting confirmations | Nothing — transient | | `confirmed` | The payment is confirmed on-chain; fiat payout starts | Watch the off-ramp | | `completed` | Crypto leg fully settled | Terminal | | `transaction_failed` | The on-chain transaction reverted or ran out of gas (wallets usually catch a missing allowance in simulation before broadcast — nothing lands on-chain and the quote stays `created`) | Terminal for this quote — show the failure, let the user retry with a new quote. Check the ERC-20 allowance first | | `insufficient_funds` | The wallet didn't cover `requiredTokenInput` | Terminal for this quote — ask the user to top up and retry with a new quote | | `expired` | The quote wasn't fulfilled in time | Terminal — create a new quote | | `failed` | The quote failed validation after creation | Terminal — create a new quote; contact support if it persists | | `refunded` | The payment was refunded | Terminal — reconcile against the off-ramp | ### Off-ramp statuses (fiat leg) | `status` | Meaning | What to do | | - | - | - | | `awaiting_funding` | Record exists, waiting for the crypto to land | Transient | | `queued` | Payout queued for submission | Transient (in sandbox the fiat leg parks here — no real payout is submitted) | | `in_flight` | Payout submitted to the banking rail | Show "on its way" with the rail's [timing](/guides/timing); never promise a delivery time the rail doesn't guarantee | | `completed` | Fiat delivered to the destination | Terminal — show completion and `completedAt` | | `failed` | The payout failed (for example, a bank rejection) | Terminal — surface it and offer a [refund](#refunds) path or support | | `canceled` | Canceled before settlement | Terminal | | `reversed` | The payout was reversed after settlement | Terminal — reconcile with support | | `refunded` | The fiat was refunded | Terminal | Poll `GET /v1/off-ramp-quotes/{id}` and `GET /v1/off-ramps/{id}`, list with `GET /v1/off-ramps`, or — better — react to [webhooks](/guides/webhooks): `payment.*` events fire per off-ramp on your integrator-level subscription. The observed sequence is `payment.created` and `payment.updated` when the off-ramp is created, `payment.updated` as it moves, and `payment.completed` when fiat lands; `payment.refunded` covers refunds. There is no `payment.failed` event — a failure arrives as `payment.updated`, so always fetch the off-ramp for the authoritative state instead of inferring it from the event name. You'll also see `account.updated` fire on the destination bank account as its state changes. ### Talking to the user Subscribe to the `offramp.*` [milestone events](/guides/webhooks#off-ramp-milestone-events) — each one is a notification moment with a copy-ready snapshot (amounts, destination name, rail, explorer link). Suggested copy per milestone: | Milestone event | User message | CTA | | - | - | - | | Quote `created` (no event — your UI moment) | "Review the transaction" | Confirm / cancel | | Quote `transaction_pending` (no event) | "Confirming your payment…" | None — wait | | Quote `transaction_failed` / `insufficient_funds` (no event — the user's wallet rejected the payment) | "The payment didn't go through. Check your wallet has enough USDC and that you've approved the spend, then try again." | Try again (new quote) | | Quote `expired` (no event) | "This quote expired. Prices move, so here's a fresh one." | Review new quote | | `offramp.confirmed` | "Payment received — your payout to has started" | Optional: view on explorer (`transaction.explorerUrl`) | | `offramp.inFlight` | "Your money is on its way to " (name the rail's [timing](/guides/timing) — never promise a deadline the rail doesn't guarantee) | None — wait | | `offramp.completed` | "Your money has arrived" | View account / done | | `offramp.failed` | "The payout couldn't be completed. Your funds are safe — we'll re-issue or refund it." | Retry payout / contact support | | `offramp.refunded` / `offramp.reversed` | "This payout was returned. The funds are back with us — here's what happens next." | Contact support | Two rules behind the copy: don't show raw statuses or error codes to users, and never say "completed" for the quote leg alone — money hasn't arrived until the **off-ramp** is `completed` (i.e. the `offramp.completed` milestone fires). ## Refunds A failed off-ramp can be reissued to its destination account: ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/off-ramps/{offRampId}/refund \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $(uuidgen)" \ # plus your integrator signing headers and the user's Authorization -d '{ "method": "account" }' ``` Only `failed` off-ramps are refundable, and only `method: "account"` (reissue the payout) is available to integrator-created off-ramps. Anything else returns a `422`. Refunds are serialized per off-ramp — a concurrent attempt gets a `409` — so send an [Idempotency-Key](/guides/idempotency) and retry safely. ## Errors you'll actually meet All errors are [RFC 9457 problem responses](/guides/errors). Branch on `status`, `type`, and `code` — never on the human-readable `detail`. | Case | Status | Notes | | - | - | - | | Missing or invalid auth | `401` | `detail` is deliberately generic (`Authentication failed`). Check clock drift first — timestamps more than 5 minutes off are the common cause of mystery 401s | | Capability not active | `403` | Title `Capability Not Available`, with `capability`, `capabilityStatus`, and `nextRequirement` extensions. Drive the user through the requirement's `actionUrl` | | Missing `tokenAddress` | `400` | Required on every chain except Bitcoin, Dash, and XRP | | Malformed `amount` or `accountId` | `400` | `amount` must be a decimal string; `accountId` is a 24-char hex id | | Unsupported rail / currency / amount mode combination | `400` | Check the destination's `supportedRails` and [What we support](/guides/supported) | | Unknown or foreign quote / off-ramp / bank account | `404` | Another user's resources are indistinguishable from missing ones — never a `403` | | Transaction params for a `send_to_address` quote | `422` | Send crypto to `sendTo.address` instead | | Refund of a non-failed or ineligible off-ramp | `422` | Only `failed` off-ramps refund, integrator payments reissue via `method: "account"` | | Concurrent refund | `409` | One refund in flight per off-ramp; retry after it settles | | Idempotency-key reuse with a changed body | `422` | `urn:problem-type:idempotency-conflict` — replay only byte-identical requests | | Provider or network outage | `502` / `503` | Retryable — back off and retry | ## Sandbox testing The sandbox covers this entire API and the fiat side — but there is **no testnet for the off-ramp chain leg**, so a real end-to-end run means a small mainnet transaction with real USDC. No approval is needed in sandbox; in **production**, quote creation requires live-money approval first. The full procedure, including how to get test funds returned, is in [Sandbox & Testing](/guides/sandbox#testing-the-off-ramp). ## A build order that works The fastest path from zero to a working end-to-end sandbox demo: 1. **Auth first.** HMAC-sign `GET /v1/users/me` until it returns `200`. Don't move on until it does — everything else assumes signing works. 2. **Create a user**, store the `userId` and `ak_…` key. 3. **Bypass KYC** (`POST /v1/sandbox/bypass-kyc` with `{ "country": "US" }`), then read capabilities from `GET /v1/users/me`. 4. **Register your webhook endpoint and verify signatures** — early, while there's nothing at stake. It makes every later step observable. 5. **Off-ramp**: add a dummy bank account (valid routing number, any account number) → create a quote → fetch transaction parameters → sign and submit a small mainnet transaction with real USDC → watch both lifecycles move. Budget a handful of pennies, and ask us to return them when you're done. 6. **On-ramp**: accept terms → create an auto-ramp account → confirm `status: "active"` → read the deposit instructions → simulate a deposit. 7. **Go live**: swap the base URL and credentials for production. ## Integration checklist * [ ] Bank-account entry validates the routing number and confirms the account number with a masked re-entry step * [ ] Quotes are created with `amountMode` chosen deliberately, `tokenAddress` always sent, and `amount` as a decimal string * [ ] `fulfillment` is branched on: `sign_transaction` fetches parameters, `send_to_address` shows address, amount, and `expiresAt` * [ ] The ERC-20 approve of `contractAddress` for `requiredTokenInput` happens before the payment transaction * [ ] Quote and off-ramp statuses are stored as two separate lifecycles * [ ] Failures (`transaction_failed`, `insufficient_funds`, `expired`, `failed`) surface retry-with-new-quote, never a silent spinner * [ ] Webhooks are triggers: verify the signature, `2xx` fast, then fetch the resource * [ ] Missed webhooks are recoverable by polling `GET /v1/off-ramps` and `GET /v1/integrator/webhooks/deliveries` * [ ] Refunds send `Idempotency-Key` and handle `409`/`422` ## Related The reverse direction: fiat in, crypto out. Track payment status changes without polling. How long each rail takes. Fee tiers by rail, token, and size. # On-ramp: fiat to crypto Source: https://docs.spritz.finance/guides/use-cases/on-ramp Bring fiat into crypto with auto-converting deposit accounts. An [on-ramp](/guides/definitions#on-ramp) converts fiat into crypto. There are two ways to bring fiat in: * **[Auto-ramp accounts](/guides/definitions#auto-ramp-account)** (this guide): a dedicated virtual bank account for the user. Anything paid into it over ACH, FedNow, wire, or SEPA auto-converts to crypto and lands at a wallet address. Good for recurring or third-party funding, like payroll or invoices. * **[Linked bank on-ramp](/guides/use-cases/linked-bank-onramp)**: link the user's bank and pull funds on demand via ACH debit. ## Before you start * Authenticate as an integrator and act on behalf of a verified user. See [Authentication](/guides/authentication) and [Onboarding](/guides/onboarding). * Confirm the user's `fiat_to_crypto` capability is `active` — read it from `GET /v1/users/me`, don't assume it from KYC status. US users typically see `requirements_needed` with a `terms_acceptance` requirement first: send the user through the requirement's `actionUrl` hosted flow, then record the acceptance with `POST /v1/users/me/terms`. The provider activates the user's account **asynchronously** after terms acceptance. The capability can already read `active` while the provider is still catching up — for up to about 30 seconds, creation fails with `400` and detail `The customer account is not active`. Retry with backoff instead of surfacing it to the user. ## Create an auto-ramp account ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/auto-ramp-accounts \ -H "Content-Type: application/json" \ # plus your integrator signing headers and the user's Authorization (see Authentication) -d '{ "address": "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18", "network": "base", "token": "USDC" }' ``` ```json theme={null} { "id": "6a43ac369288351e982157b9", "status": "active", "network": "base", "address": "0x742d35cc6634c0532925a3b844bc9e7595f2bd18", "token": "USDC", "currency": "USD", "depositInstructions": { "type": "us", "bankName": "Lead Bank", "bankAddress": "1800 North Pole St., Orlando, FL 32801", "bankRoutingNumber": "101019644", "bankAccountNumber": "1234567890", "paymentRails": ["ach_push", "fednow", "wire"] } } ``` The deposit instructions are what the user (or their employer, or their customer) sends money to. Anything that arrives auto-converts to `token` on `network` and settles to `address` — no further API call is needed to trigger the conversion. Three things that catch people out: 1. **The account is keyed to the (token + network + destination wallet) triple.** You are not creating "a virtual account for the user" — you're creating "a virtual account for USDC on Base going to `0xABC…`". Change the destination wallet and you get a whole new virtual account with new deposit details; the old one doesn't follow. Store the destination address alongside the account in your database, and when a user changes their receiving wallet, expect to create a new auto-ramp account and update the deposit instructions everywhere you've shown them — including any saved payee the user set up at their own bank. That last one is the real-world failure mode: a recurring transfer pointed at the old details. 2. **The beneficiary is the end user**, not Spritz and not you. The user is sending money to an account in their own name — exactly what their bank expects to see. Present it that way in your UI. 3. **Check `status` before showing deposit instructions.** Only `active` accounts should receive money. Confirm it on every render. Creating an account is idempotent for the (token + network + destination wallet) triple. If the user already has an account for that triple, `POST /v1/auto-ramp-accounts` returns it with the same `id` and deposit instructions; it doesn't create a second account. Spritz handles one create request per triple at a time. A concurrent request waits briefly for the other to finish and then returns the same account. If the other request is still running, it returns `409` with `code: "AUTO_RAMP_ACCOUNT_CREATE_IN_PROGRESS"` and `retryable: true`. Retry it after a short delay: the retry returns the account the other request created. A `201` doesn't tell you whether the account is new. The returned account can be in a state other than `active`, so check its `status` before you show its deposit instructions. ### Valid combinations and sizes Call `GET /v1/on-ramps/supported-pairs` for the network, token, and rail combinations the user's region supports, with each pair's `minAmount` — don't hardcode the list. An unsupported combination returns `400` (`Unsupported Configuration`), and an unsupported-region request tells you the same way. To preview a deposit before the user sends it, call `GET /v1/auto-ramp-accounts/{id}/estimate?amount=100`: ```json theme={null} { "input": { "amount": "100.00", "currency": "USD" }, "fees": { "total": "1.00", "currency": "USD", "breakdown": { "…": "…" } }, "output": { "amount": "99.00", "token": "USDC", "network": "base", "address": "0x…" }, "rate": { "value": "1", "source": "peg", "asOf": "2026-09-03T16:09:09.229Z" } } ``` On-ramps start at 1%, with a $2 minimum on ACH debit and an extra $20 on wire-funded ones. Instant ACH release adds 1% — see [End-user pricing](/guides/pricing). ## Track conversions Each deposit that converts produces an on-ramp record. List with `GET /v1/on-ramps`, read one with `GET /v1/on-ramps/{id}`, or react to [webhooks](/guides/webhooks): `onramp.created`, `onramp.updated`, and `onramp.completed` fire as deposits are detected, converted, and delivered. | `status` | Meaning | What to do | | - | - | - | | `awaiting_payment` | Deposit expected, not yet seen | Show the deposit instructions; nothing to reconcile | | `processing` | Deposit arrived, conversion under way | Transient — show "converting" | | `partially_delivered` | Part of the crypto has landed | Transient — keep waiting, don't re-credit yet | | `completed` | Crypto delivered to the destination wallet | Terminal — credit the user from this record's `output` | | `in_review` | The deposit is under compliance review | Show review state; wait for a webhook, don't retry | | `failed` | The conversion failed | Terminal — surface it and reconcile with support | | `cancelled` | Cancelled before conversion | Terminal (note the double-l spelling — the off-ramp resource uses `canceled`) | | `reversed` | The delivery was reversed | Terminal — reconcile with support | | `refunded` | The fiat was refunded | Terminal | Wire deposits settle the same banking day; ACH takes one to two banking days; FedNow is near-instant. See [Settlement timing](/guides/timing). Settlement is not finality on ACH: a push payment can be returned after it settles, in windows running from a couple of banking days out to 60 calendar days (see [ACH returns](/guides/ach-returns) for the windows and buckets). A return after delivery surfaces as the on-ramp moving to `reversed` — so if you credit users on your side when an on-ramp completes, a later return is your exposure to think about. ## Sandbox testing The sandbox simulates a deposit arriving at an auto-ramp account — no real funds move, but the full conversion path runs and fires real `onramp.*` webhooks: ```bash theme={null} curl -X POST https://sandbox.spritz.finance/v1/sandbox/auto-ramp-accounts/{id}/deposit \ -H "Content-Type: application/json" \ # plus your integrator signing headers and the user's Authorization -d '{ "amount": "10.00" }' ``` The deposit settles the on-ramp to `completed` immediately. Terms acceptance is also headless in sandbox: `POST /v1/users/me/terms` accepts any opaque `agreementId`. The simulated on-ramp's `output.txHash` is **synthetic** — a real-looking hash that does not exist on-chain. Don't render it as an explorer link in your sandbox testing; gate explorer links on your environment. See [Sandbox & Testing](/guides/sandbox). ## Integration checklist * [ ] UI is driven from `capabilities`, and `terms_acceptance` is completed before account creation * [ ] Creation retries (or defers) through the provider's asynchronous activation window instead of surfacing `The customer account is not active` to users * [ ] Deposit instructions are only shown for `active` accounts * [ ] The (token + network + wallet) triple is stored with the account, and a wallet change creates a new account plus new instructions everywhere they're saved * [ ] The beneficiary is presented as the end user * [ ] Valid pairs and minimums come from `GET /v1/on-ramps/supported-pairs`, not a hardcoded list * [ ] `onramp.*` webhooks drive updates; `GET /v1/on-ramps` reconciles misses ## Related Pull funds from a linked bank account instead. The reverse direction: crypto in, fiat out. Networks, tokens, and currencies. How long each rail takes. # Push to debit card Source: https://docs.spritz.finance/guides/use-cases/push-to-debit Pay an off-ramp out to a user's Visa or Mastercard debit card in minutes, without card numbers ever touching your servers. Push to card is an [off-ramp](/guides/use-cases/off-ramp) whose destination is the user's own debit card instead of a bank account. Funds land on the card in minutes, around the clock. Everything about the quote, the on-chain payment, and the fiat leg is the same as the bank-account flow — this guide covers only what differs: collecting the card safely and choosing the `push_to_card` rail. Push to card is available to verified US users, in USD, for Visa and Mastercard debit cards. Credit cards are not eligible, and the card network has the final say on whether a given card can receive a push. ## How card data stays out of scope Spritz never accepts a raw card number, and neither should you. Card entry runs inside an [Evervault](https://evervault.com) Card component: an iframe served from Evervault's domain that encrypts the card number in the user's browser before your code ever sees it. What comes out is an opaque `ev:` token plus the plaintext metadata Spritz needs (expiry, last four, BIN, brand). You forward those to `POST /v1/debit-cards`; only Spritz's infrastructure can decrypt the token, and only to hand the card to the payout network. The component needs two identifiers to initialise: an Evervault **team ID** and an **app ID**. They are public-key material, not credentials — anyone holding them can only encrypt *to* Spritz, never decrypt — but they are environment-specific, so a card encrypted for sandbox cannot be used in production and vice versa. Spritz provides the Evervault team ID and the app IDs for sandbox and production during onboarding. If you do not have them, ask your Spritz contact. ## Before you start * Authenticate as an integrator acting for a user. See [Authentication](/guides/authentication). * The user's capabilities on `GET /v1/users/me` include the pair `product: "crypto_to_fiat"` and `method: "push_to_card"` with `status: "active"`. See [Onboarding](/guides/onboarding#3-read-capabilities). Adding a card without it returns `403`. * You have the Evervault team ID and the app ID for the environment you are calling. ## Step 1 — Collect the card in the browser Install the Evervault SDK for your stack (`@evervault/react`, `@evervault/js`, or the [mobile SDKs](https://docs.evervault.com/sdks)) and render the Card component with `number` and `expiry` fields. Spritz does not need the CVC — leave it out. ```tsx theme={null} import { EvervaultProvider, Card, type CardPayload } from "@evervault/react"; function AddDebitCard({ onCaptured }: { onCaptured: (payload: CardPayload) => void }) { return ( { if (payload.isValid && payload.isComplete) onCaptured(payload); }} /> ); } ``` The `onChange` payload carries everything the API needs: | Payload field | Sent as | Notes | | - | - | - | | `card.number` | `encryptedCardNumber` | Encrypted `ev:` token. Never log or store it yourself. | | `card.expiry.month` | `expiryMonth` | Plaintext, may be unpadded (`"9"`). | | `card.expiry.year` | `expiryYear` | Plaintext, two digits (`"29"`). | | `card.lastFour` | `cardLastFour` | Plaintext. | | `card.bin` | `cardBin` | Plaintext, six to eight digits. | | `card.brand` | `cardBrand` | `visa` or `mastercard`. | Collect the cardholder's first and last name and their US billing address in your own form. The name must be the name printed on the card — Spritz checks it against the user's verified identity — and the card network requires the billing address to accept pushes. ## Step 2 — Add the card Send the captured values from your backend, signed with your integrator key, on behalf of the user. Your backend only ever handles the encrypted token, which keeps it outside PCI scope. ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/debit-cards \ -H "Content-Type: application/json" \ # plus your integrator signing headers and the user's Authorization (see Authentication) -d '{ "encryptedCardNumber": "ev:SWFSS:…", "expiryMonth": "9", "expiryYear": "29", "cardLastFour": "4242", "cardBin": "424242", "cardBrand": "visa", "cardholderFirstName": "Jane", "cardholderLastName": "Doe", "billingAddress": { "line1": "123 Main St", "city": "Austin", "state": "TX", "postalCode": "78701", "country": "US" }, "label": "Chase debit" }' ``` A `201` returns the card, ready to use: ```json theme={null} { "id": "6a53f91ad75586ce264cd790", "status": "active", "network": "visa", "cardNumberLast4": "4242", "expiryMonth": 9, "expiryYear": 2029, "label": "Chase debit", "currency": "USD", "isTokenized": true, "createdAt": "2026-09-08T10:12:44.118Z" } ``` Card statuses: | Status | Meaning | What to do | | - | - | - | | `active` | Ready for payouts | Offer it as a destination | | `rejected` | The card network refused it — expired, inactive, or a card type that cannot receive payouts | Terminal. Ask the user to add a different card | | `inactive` | Removed | Hide it | | `action_required` | Stored, but missing cardholder details | Only seen on cards added before name and billing address were required. Collect them and call `PATCH /v1/debit-cards/{id}/cardholder-info`; the `requirements` array lists the fields | List a user's cards with `GET /v1/debit-cards` (`{ "data": [...], "hasMore": false }` — no pagination, users hold few cards), read one with `GET /v1/debit-cards/{id}`, and remove one with `DELETE /v1/debit-cards/{id}`. A deleted card reads back as `404`. ### Errors when adding a card | Situation | Status | `type` | Handle it | | - | - | - | - | | Push to card is not active for the user | `403` | `urn:problem-type:forbidden` | Re-read capabilities; the user may need to finish verification, or is outside the US | | Cardholder name is not a plausible personal name and does not match the verified identity | `422` | `urn:problem-type:business:verification-failed` | Ask the user to enter the name exactly as printed on the card. Do not retry the same name | | A field fails validation (bad BIN, unsupported brand, missing billing address) | `400` | `urn:problem-type:validation:invalid-fields` | Fix the request; the `errors` array names each field | | The card could not be registered for payouts | `503` | `detail: "Failed to create debit card with provider"` | Nothing was stored. This is usually the card, not an outage — expired, a credit card, or an issuer that does not accept pushes. Show "This card can't receive payouts" and let the user try another card. Do not retry in a loop | ## Step 3 — Quote, pay, and track From here the [off-ramp guide](/guides/use-cases/off-ramp#step-2--create-the-quote) applies unchanged. Pass the card's `id` as `accountId` and choose the `push_to_card` rail: ```json theme={null} { "accountId": "6a53f91ad75586ce264cd790", "amount": "100.00", "amountMode": "output", "rail": "push_to_card", "chain": "base", "tokenAddress": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" } ``` * **Timing**: minutes, seven days a week. The off-ramp usually moves `in_flight` → `completed` within a few minutes of the on-chain confirmation. See [Settlement timing](/guides/timing). * **Pricing**: the crypto-to-fiat base rate plus the `push_to_card` adjustment, with a weekend and US-bank-holiday surcharge. See [End-user pricing](/guides/pricing). * **Failure**: if the network declines the push after the crypto has been paid, the off-ramp reaches `failed` and the [refund flow](/guides/use-cases/off-ramp#refunds) applies. A decline that is a verdict on the card itself (expired, inactive, cannot receive pushes) also flips the card to `rejected` — collect a different card before offering a reissue. Webhooks are the same `payment.*` and `offramp.*` events as any off-ramp. See [Webhooks](/guides/webhooks). ## Sandbox testing Sandbox has its own Evervault app ID. Initialise the Card component with it when targeting `https://sandbox.spritz.finance`; a token encrypted for the production app is refused there, and the other way round. Card entry accepts any Luhn-valid Visa or Mastercard number with a future expiry, and the add flow runs against a sandbox card registration, so the `201`, `403`, `422`, and `400` paths above are all reachable. As with every sandbox off-ramp, the fiat leg parks at `queued` — no push is submitted, so `completed`, `failed`, and the `rejected` card state are contract-tested from this guide, not end-to-end tested. Exercise your handling of them with fixtures. See [Sandbox](/guides/sandbox) for the environment as a whole. ## Integration checklist * [ ] Card number is captured only inside the Evervault Card component, with the team ID and the app ID for the environment you are calling * [ ] Your backend forwards the encrypted token and never logs it, and your frontend never posts it to Spritz directly (that would expose your integrator key) * [ ] `expiryMonth` and `expiryYear` are forwarded as the component returns them, not re-formatted * [ ] Cardholder name is collected as first and last name, as printed on the card * [ ] `403` and `422` on card add produce specific user guidance, and `503` offers "try a different card" rather than a retry * [ ] Quotes use `rail: "push_to_card"` with the card `id` as `accountId` * [ ] A `rejected` card is hidden from the destination picker and the user is prompted to add another ## Related The full off-ramp lifecycle this guide builds on. Reading capabilities to know when push to card is active. Fee tiers by rail, including push to card. Test users, bypasses, and what the sandbox can and cannot simulate. # Webhooks Source: https://docs.spritz.finance/guides/webhooks Track settlement and status changes asynchronously. Money movement is asynchronous. An off-ramp is requested, then converts, then settles to a bank account over time. Webhooks let Spritz notify your backend the moment something changes, so you don't have to poll. Webhooks are configured per integrator, so you manage them with your integrator credentials (HMAC). See [Authentication](/guides/authentication). Webhooks are **global, not per user**. You subscribe once and receive events for all of your users. There's no need to register a webhook per user, and you don't pass a user API key when managing webhooks. These are integrator endpoints, so they use your integrator signing headers only. ## Register an endpoint Create a webhook by pointing it at an HTTPS URL on your backend and listing the events you want. Use `"*"` to subscribe to every current and future event. ```bash theme={null} curl -X POST https://platform.spritz.finance/v1/integrator/webhooks \ -H "Content-Type: application/json" \ # plus your integrator signing headers (see Authentication) -d '{ "url": "https://api.example.com/webhooks/spritz", "events": ["onramp.created", "achDebit.authorized", "achDebit.deliveryProgress", "achDebit.delivered", "achDebit.refunded", "achDebit.returned"] }' ``` ```json theme={null} { "id": "6a43ac369288351e982157c4", "url": "https://api.example.com/webhooks/spritz", "events": ["onramp.created", "achDebit.authorized", "achDebit.deliveryProgress", "achDebit.delivered", "achDebit.refunded", "achDebit.returned"] } ``` You can list, update, and delete webhooks too: | Method | Path | Purpose | | - | - | - | | `GET` | `/v1/integrator/webhooks` | List your webhooks | | `POST` | `/v1/integrator/webhooks` | Create a webhook | | `PATCH` | `/v1/integrator/webhooks/{webhookId}` | Update a webhook's events | | `DELETE` | `/v1/integrator/webhooks/{webhookId}` | Delete a webhook | | `POST` | `/v1/integrator/webhook-secret` | Set the secret used to sign deliveries | | `GET` | `/v1/integrator/webhooks/deliveries` | Inspect delivery status and history | | `POST` | `/v1/integrator/webhooks/deliveries/{deliveryId}/redeliver` | Send a past delivery again | ## Events | Event | Fires when | | - | - | | `account.created` | An account (bank account, card, bill) is created | | `account.updated` | An account's details change | | `account.deleted` | An account is removed | | `payment.created` | A payment (off-ramp) is initiated | | `payment.updated` | A payment's details change | | `payment.completed` | A payment completes | | `payment.refunded` | A payment is refunded | | `verification.status.updated` | A user's identity verification status changes | | `capabilities.updated` | A user's capabilities change (for example on-ramp becomes active) | | `onramp.created` | An on-ramp record is created after a deposit is authorized | | `onramp.updated` | An on-ramp's status, delivery, or reversal details change | | `onramp.completed` | Crypto delivery completes. For ACH debit, the bank can still return the debit later | | `achDebitReturn.created` | An ACH debit return is recorded | | `achDebitReturn.updated` | An ACH debit return's details change | | `achDebit.authorized` | The user authorizes a linked-bank deposit | | `achDebit.deliveryProgress` | A new partial amount of crypto is confirmed onchain | | `achDebit.delivered` | The full crypto principal is confirmed onchain | | `achDebit.refunded` | Spritz initiates the deposit's bank refund | | `achDebit.returned` | The bank returns the debit and its user action is classified | | `offramp.confirmed` | Off-ramp milestone: crypto payment confirmed on-chain, payout created | | `offramp.inFlight` | Off-ramp milestone: payout submitted to the banking rail | | `offramp.completed` | Off-ramp milestone: fiat delivered | | `offramp.failed` | Off-ramp milestone: a leg failed terminally | | `offramp.refunded` | Off-ramp milestone: funds returned | | `offramp.reversed` | Off-ramp milestone: a settled payout was clawed back | | `onrampCredit.depositDetected` | Credit on-ramp milestone: fiat arrived at the auto-ramp account | | `onrampCredit.completed` | Credit on-ramp milestone: crypto delivered | | `onrampCredit.failed` | Credit on-ramp milestone: conversion failed | | `onrampCredit.reversed` | Credit on-ramp milestone: the fiat deposit was clawed back | | `onrampCredit.refunded` | Credit on-ramp milestone: the fiat deposit was returned | Subscribe to `"*"` to receive all of these, including events added later. ## Receiving a delivery Spritz sends a `POST` to your URL with a JSON body identifying what changed: ```json theme={null} { "userId": "63d12d3b577fab6c6382136e", "id": "6368e3a3ec516e9572bbd23b", "event": "onramp.completed", "eventId": "evt_3f9a1c0e8b7d4a2f9e6c5b4a3d2e1f0a", "occurredAt": "2026-10-08T14:02:11Z" } ``` | Field | Always present | Meaning | | - | - | - | | `event` | yes | Which event fired, from the table above | | `userId` | yes | The user the event concerns | | `eventId` | yes | Opaque identifier for this event occurrence. It stays the same across retries and redeliveries. | | `occurredAt` | yes | When the change happened, as an ISO 8601 timestamp | | `id` | no | The resource that changed | `id` is omitted for events whose subject is the user rather than a separate resource — `capabilities.updated` is the case you are most likely to meet. Key your handler off `event` and treat `id` as optional. Every delivery also carries these headers: | Header | Value | | - | - | | `Spritz-Delivery-Id` | The delivery's `id` in [delivery history](#retries-and-delivery-outcomes). The same on every attempt and redelivery. | | `Spritz-Signature` | Timestamped signature. See [Verifying signatures](#verifying-signatures). | | `Signature` | Legacy signature, kept for existing integrations | Deduplicate on `eventId`: store it with a unique constraint and skip any delivery whose `eventId` you have already accepted. One resource can still produce several legitimate events, each with its own `eventId`, so never deduplicate on `id`. ```ts theme={null} const { event, userId, id } = payload switch (event) { case "onramp.completed": await refreshOnRamp(id!) break case "capabilities.updated": // No `id` — the subject is the user. await refreshUser(userId) break } ``` The body tells you which resource changed, not its full state. Treat it as a trigger: fetch the resource from the API to get authoritative state before acting on it. For `achDebitReturn.*`, `id` is the public `dr_...` return ID. Fetch it with `GET /v1/integrator/ach-debit/returns/{id}`. Then use its `depositId` and `sourceId` to refresh the deposit and funding source. For `onramp.*`, fetch the on-ramp. When its `source` contains a `depositId`, fetch `GET /v1/deposits/{depositId}` with the user's authorization. These generic resource events keep your UI and local state current. **Do not send ACH push notifications from generic `onramp.*` or `achDebitReturn.*` events.** A retry can produce the same current state, so state comparison alone cannot identify one notification occurrence. For `payment.*`, `id` is the off-ramp id — fetch `GET /v1/off-ramps/{id}` with the user's authorization (or your HMAC credentials acting for them). A typical off-ramp fires `payment.created` and `payment.updated` when it's created, `payment.updated` as it moves through the rails, and `payment.completed` when fiat lands. **There is no `payment.failed` event** — a failure arrives as `payment.updated`, so never infer state from the event name; read the off-ramp's `status`. You'll also see `account.updated` fire on the destination bank account as its state changes. For user-facing notifications, prefer the [`offramp.*` milestone events](#off-ramp-milestone-events) — they carry copy-ready snapshots; use `payment.*` only to keep local state in sync. ## Off-ramp milestone events The six `offramp.*` events are the notification contract for crypto-to-fiat: one event per user-meaningful moment, each with an immutable snapshot taken at the transition. They follow the same handling contract as `achDebit.*`: deduplicate on `eventId`, drop stale `sequence` values, and use a durable inbox (see the [handler rules](#ach-debit-communication-events) below). ```json theme={null} { "schemaVersion": "1", "eventId": "6a99b8f2c4e1a90d3b7f2e15", "sequence": 3, "occurredAt": "2026-09-03T18:16:01.000Z", "userId": "63d12d3b577fab6c6382136e", "integratorId": "6a9995f184f0eaa795879343", "event": "offramp.completed", "id": "6a99b80613a0c37251651788", "milestone": "completed", "offRamp": { "id": "6a99b80613a0c37251651788", "quoteId": "6a99b7bacc18094dfe644ba6", "status": "completed", "input": { "amount": "0.02", "token": "USDC", "chain": "base" }, "output": { "amount": "0.01", "currency": "USD", "rail": "ach_standard", "accountId": "6a9996a7ff61211ef039d166", "accountName": "Chase Checking ••6789" }, "fees": { "amount": "0.01", "currency": "USD" }, "transaction": { "hash": "0xb4af…6357", "explorerUrl": "https://basescan.org/tx/0xb4af…6357" }, "failureMessage": null } } ``` **Event identity:** * `eventId` is an opaque string that identifies one notification occurrence. It stays the same across delivery retries and replays. Don't parse its format. * Each milestone fires at most once per off-ramp, so one off-ramp produces at most six events. * `sequence` is per off-ramp. It starts at `1` and increases in the order the milestones occurred. Drop any event whose `sequence` is not above the highest you've accepted for that `id`. * Don't treat a gap in `sequence` as a missed event. Milestones can be skipped; for example, a payout can reach `completed` without an `offramp.inFlight` event. Use [delivery history](#retries-and-delivery-outcomes) to recover a missed delivery. * A [reissued payout](/guides/use-cases/off-ramp#refunds) is a new off-ramp with a new `id`. It gets its own `offramp.confirmed` event, and its `sequence` restarts at `1`. **Payload fields:** | Object | Required fields | Nullable | | - | - | - | | Top level | `schemaVersion`, `eventId`, `sequence`, `occurredAt`, `userId`, `integratorId`, `event`, `id`, `milestone`, `offRamp` | `integratorId` | | `offRamp` | `id`, `quoteId`, `status`, `input`, `output`, `fees`, `transaction`, `failureMessage` | `quoteId`, `input`, `fees`, `transaction`, `failureMessage` | | `offRamp.output` | `amount`, `currency`, `rail`, `accountId`, `accountName` | `rail`, `accountName` | | `offRamp.transaction`, when present | `hash`, `explorerUrl` | `hash`, `explorerUrl` | `offRamp.status` uses the [off-ramp status](/guides/use-cases/off-ramp#off-ramp-statuses-fiat-leg) values, so it can be `canceled` even though no milestone has that name. | `event` | `milestone` | The moment | Suggested user notification | | - | - | - | - | | `offramp.confirmed` | `confirmed` | The crypto payment is confirmed on-chain and the fiat payout is created | "Payment received — your payout to has started" | | `offramp.inFlight` | `in_flight` | The payout is submitted to the banking rail | " is on its way to " — name the rail's [timing](/guides/timing), never promise a deadline the rail doesn't guarantee | | `offramp.completed` | `completed` | Fiat delivered to the destination | "Your money has arrived" | | `offramp.failed` | `failed` | A leg failed terminally | "The payout couldn't be completed — we'll make it right" + support/retry CTA | | `offramp.refunded` | `refunded` | Funds returned | "This payout was refunded" | | `offramp.reversed` | `reversed` | A settled payout was clawed back by the rail | "This payout was returned by the bank — contact support" | The event and milestone pair is fixed. `quoteId` links the off-ramp to its quote (null for auto-ramp-address deposits, which have no quote). `transaction` carries the funding transaction's hash and explorer link once known. `failureMessage` is reserved for public-safe failure copy and is currently always `null` — branch on the milestone, not on absent detail. The `onrampCredit.*` family (`depositDetected`, `completed`, `failed`, `reversed`, `refunded`) is the same contract for auto-ramp-account funding deposits — fiat in, crypto out. ## ACH debit communication events The five `achDebit.*` events are the push-notification contract. Unlike generic resource events, each delivery contains an immutable milestone snapshot: ```json theme={null} { "schemaVersion": "1", "eventId": "0198e4a8-70b2-7c11-a9a3-2104b3d7eaf1", "sequence": 2, "occurredAt": "2026-08-26T12:01:00.000Z", "userId": "63d12d3b577fab6c6382136e", "event": "achDebit.deliveryProgress", "id": "dep_01K3NQ4QGM7Y5ZP0DRW1M4V8NC", "milestone": "delivery_progress", "deposit": { "id": "dep_01K3NQ4QGM7Y5ZP0DRW1M4V8NC", "asset": "USDC", "destinationAddressDisplay": "9xQeWv…wM9R", "principalAmountUsd": "500.00", "totalDebitAmountUsd": "506.00", "confirmedReleasedAmountUsd": "100.00", "debitStatus": "submitted", "releaseStatus": "partial", "releaseDecisionMode": "early_partial", "instantPortionUsd": "100.00", "settlementPortionUsd": "400.00", "userFeeUsd": "6.00" }, "fundingSource": { "id": "fs_01K3NQ4QGM7Y5ZP0DRW1M4V8NC", "institutionName": "Chase", "accountMask": "6789" }, "achReturn": null } ``` `eventId` is an opaque string that identifies one notification occurrence and stays unchanged across delivery retries and replays. Don't parse its format. `sequence` starts at `1` and increases for each communication milestone on that deposit. Don't treat a gap in `sequence` as a missed event. Use [delivery history](#retries-and-delivery-outcomes) for recovery instead. `achDebit.deliveryProgress` can fire more than once per deposit, once for each newly confirmed partial amount. Each one has its own `eventId` and `sequence`. Deliveries are still at least once and can arrive out of order. `id`, `deposit.id`, and `fundingSource.id` are the same public IDs used by the deposit and funding-source APIs. Each `achDebit.*` payload is versioned and fully typed in the OpenAPI contract. These fields are always present: | Object | Required fields | | - | - | | Top level | `schemaVersion`, `eventId`, `sequence`, `occurredAt`, `userId`, `event`, `id`, `milestone`, `deposit`, `fundingSource`, `achReturn` | | `deposit` | `id`, `asset`, `destinationAddressDisplay`, `principalAmountUsd`, `totalDebitAmountUsd`, `confirmedReleasedAmountUsd`, `debitStatus`, `releaseStatus`, `releaseDecisionMode`, `instantPortionUsd`, `settlementPortionUsd`, `userFeeUsd` | | `fundingSource` | `id`, `institutionName`, `accountMask` | | `achReturn`, when returned | `id`, `amountUsd`, `cryptoStateAtReturn`, `userAction` | The event and milestone pair is fixed: | `event` | `milestone` | `achReturn` | | - | - | - | | `achDebit.authorized` | `authorized` | `null` | | `achDebit.deliveryProgress` | `delivery_progress` | `null` | | `achDebit.delivered` | `delivered` | `null` | | `achDebit.refunded` | `refunded` | `null` | | `achDebit.returned` | `returned` | Required return snapshot | Your webhook handler must durably accept the event before returning `2xx`: 1. Begin a database transaction. 2. Insert `eventId` into a webhook inbox with a unique constraint. If it already exists, commit and return `2xx` without enqueueing another push. 3. Compare `sequence` with the highest accepted sequence for `id`. If it is lower or equal, record it as stale, commit, and return `2xx`. 4. Update the deposit's highest sequence and insert one push-outbox row keyed by `eventId` in the same transaction. 5. Commit and return `2xx`. A separate worker sends the push. This is a small generic inbox/outbox, not ACH-specific state inference. Use `eventId` as the push provider's idempotency or collapse key when the provider supports one. Never mark an event accepted only in memory. HTTP delivery cannot guarantee that an external push provider displays a message exactly once. The stable event ID, sequence guard, transactional push outbox, and provider idempotency key close every duplicate path your integration can control. Use the immutable event snapshot for push copy. Refetch the deposit separately for the current UI; a later state must not change the meaning of an earlier notification. See [ACH debit user experience](/guides/ach-debit-user-experience) for the exact copy. ## Verifying signatures Each delivery is signed so you can confirm it came from Spritz, wasn't modified in transit, and isn't an old request replayed. Set a webhook secret with `POST /v1/integrator/webhook-secret`, then verify the `Spritz-Signature` header against the raw request body. Always verify against the raw body, before parsing JSON. ``` Spritz-Signature: t=1791468131,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` | Part | Meaning | | - | - | | `t` | Unix time in seconds when Spritz signed this attempt. Each attempt has a new `t`. | | `v1` | Lowercase hex HMAC-SHA256 of `"."`, keyed with a webhook secret. There can be more than one `v1`. | To verify: 1. Split the header on `,` and read `t` and every `v1` value. 2. Reject the request if `t` is more than 300 seconds from your current time. 3. Compute HMAC-SHA256 of `` `${t}.${rawBody}` `` with your secret. 4. Accept the request if your result matches any `v1` value, using a constant-time comparison. Otherwise reject it with `401`. ```ts theme={null} import { createHmac, timingSafeEqual } from "node:crypto" const TOLERANCE_SECONDS = 300 function verifySpritzWebhook(rawBody: string, header: string, secret: string) { const parts = header.split(",").map((part) => part.split("=")) const t = parts.find(([key]) => key === "t")?.[1] const signatures = parts.filter(([key]) => key === "v1").map(([, value]) => value) if (!t || signatures.length === 0) return false if (Math.abs(Date.now() / 1000 - Number(t)) > TOLERANCE_SECONDS) return false const expected = Buffer.from( createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"), "utf8", ) return signatures.some((signature) => { const presented = Buffer.from(signature, "utf8") return presented.length === expected.length && timingSafeEqual(presented, expected) }) } // In your handler, using the raw (unparsed) body: const header = request.headers["spritz-signature"] if (!header || !verifySpritzWebhook(rawBody, header, WEBHOOK_SECRET)) { return reply.status(401).send() } ``` * Spritz signs deliveries only after you set a secret. Until then, deliveries arrive without signature headers, so set a secret before going live and reject any request without `Spritz-Signature`. * Every attempt is signed at send time with the secrets valid at that moment. * Deduplicate on `eventId` as well. The timestamp limits how long a captured request can be replayed; `eventId` stops a replay inside that window from being processed twice. ### Rotating your secret When you set a new secret, Spritz keeps your previous secret valid for 24 hours. The `POST /v1/integrator/webhook-secret` response tells you until when, in `previousSecretValidUntil`. During that window, `Spritz-Signature` carries one `v1` for each secret, current first, so a receiver holding either secret can verify. Move your receiver to the new secret before `previousSecretValidUntil`. Spritz keeps only one previous secret. If you rotate again before `previousSecretValidUntil`, the secret you just replaced becomes the previous one and the older secret stops working immediately. Finish moving to a new secret before you rotate again. A new secret takes up to about 60 seconds to reach delivery workers. If two secret updates for your integration overlap, the second returns `409` with `code: "WEBHOOK_SECRET_UPDATE_IN_PROGRESS"` and `retryable: true`. Retry it after a short delay. ### Legacy `Signature` header Spritz still sends the original `Signature` header so existing integrations keep working. It is the lowercase hex HMAC-SHA256 of the raw body, keyed with your current secret only. It has no timestamp, so it does not protect against replay, and during a rotation it is signed only with the new secret. Use `Spritz-Signature` for new integrations and move existing ones to it. ## Retries and delivery outcomes Return a `2xx` as soon as you have durably accepted the event. If your endpoint can't accept it, Spritz retries the delivery for about 24 hours. Each delivery runs as a series of attempts: * **Within a series**, Spritz makes up to three attempts a few seconds apart. Each attempt times out after 5 seconds. Spritz honours a short `Retry-After` header within that window. * **Between series**, Spritz waits 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, 2 hours, 4 hours, 8 hours, then 8 hours. After the last series fails, the delivery is `failed`. | Your endpoint's result | What Spritz does | | - | - | | `2xx` | Delivery `succeeded`. No more attempts. | | `5xx`, `408`, `429`, a timeout, or a connection or DNS failure | Retries on the schedule above | | `3xx` | Delivery `failed`. Redirects are not followed, so register the final URL. | | Any other `4xx` | Delivery `failed`. Spritz does not retry. | Every retry sends the same body, `eventId`, and `Spritz-Delivery-Id` to the webhook's current URL. If you delete the webhook, its pending deliveries stop and are marked `failed`. Duplicates can still reach you, so handlers must stay idempotent. Choose your response by outcome: | Your handler's outcome | Respond with | Why | | - | - | - | | Event durably accepted | `2xx` | Done. | | Duplicate `eventId` already in your inbox | `2xx` | Already handled; a retry adds nothing. | | Stale `sequence` recorded | `2xx` | Accepted as stale; a retry adds nothing. | | Unknown `event` name | `2xx` | Ignore it. New events can be added, especially under `"*"`. | | Missing or invalid signature | `401` | Spritz doesn't retry it. Fix your secret configuration, then [redeliver](#redeliver-a-delivery). | | Temporary failure before durable accept (for example, a database error) | `5xx` | Spritz retries on the schedule above. | Respond within 5 seconds. Durably accepting an event means writing it to your inbox. Do slow work, such as sending a push notification or calling another API, after you respond. Webhook configuration is cached by delivery workers and can take about 60 seconds to propagate. Wait before using a newly registered endpoint or changed secret in a test. ### Delivery history Inspect deliveries with `GET /v1/integrator/webhooks/deliveries`. Results are newest first and cursor-paginated. Each record is one delivery, updated as its attempts run. Spritz retains each record and its exact payload for at least 30 days. Follow cursors until no more records remain. ```json theme={null} { "data": [ { "id": "6a9a0c4e2f1b7d3c8e5a1b20", "eventId": "evt_8c1e5f2a9b3d4e7f0a6c2b1d5e4f3a29", "status": "pending", "attempts": 3, "nextAttemptAt": "2026-10-08T14:03:05.000Z", "event": "achDebitReturn.created", "webhookUrl": "https://api.example.com/webhooks/spritz", "payload": { "event": "achDebitReturn.created", "userId": "63d12d3b577fab6c6382136e", "id": "dr_01JV7Q8M4Y8K6N2Z5P3R1T9W0X", "eventId": "evt_8c1e5f2a9b3d4e7f0a6c2b1d5e4f3a29", "occurredAt": "2026-10-08T14:02:30Z" }, "success": false, "responseStatus": 503, "timestamp": "2026-10-08T14:02:35.000Z" } ], "hasMore": false, "nextCursor": null } ``` | Field | Meaning | | - | - | | `id` | The delivery ID, also sent as `Spritz-Delivery-Id` | | `eventId` | The event occurrence. `null` on older records. | | `status` | `pending` (more attempts scheduled), `succeeded`, or `failed` (no more attempts) | | `attempts` | HTTP attempts made so far. `null` on older records. | | `nextAttemptAt` | When the next series starts, while `pending`. Otherwise `null`. | | `success`, `responseStatus`, `error`, `timestamp` | The result of the latest attempt series | | `payload` | The exact body Spritz sent | Read `error` before interpreting `responseStatus`: | Shape | Meaning | What to do | | - | - | - | | `success: true` | Your endpoint returned `2xx`. | Nothing. | | `success: false`, `responseStatus`, no `error` | Your endpoint answered with that status. | Fix the handler if `status` is `failed`; otherwise Spritz retries. | | `success: false`, `error`, no `responseStatus` | Spritz received no usable response: a timeout or connection failure. | Treat the outcome as unknown. Your endpoint may still have processed it. | | `success: false`, `error`, with `responseStatus` `500` or `504` | An older record. That status is Spritz's classification, not your endpoint's response. | Same as the row above. | | `success: false`, neither field | An older record with no recorded outcome. | Reconcile the resource. | ### Redeliver a delivery Send a past delivery again with `POST /v1/integrator/webhooks/deliveries/{deliveryId}/redeliver`. Spritz sends the same `payload`, `eventId`, and `Spritz-Delivery-Id` to the webhook's current URL and starts a fresh retry schedule. The response is the delivery record, now `pending`. | Response | Meaning | | - | - | | `200` | Redelivery scheduled | | `404`, `code: "WEBHOOK_DELIVERY_NOT_FOUND"` | No such delivery for your integration | | `409`, `code: "WEBHOOK_NOT_FOUND"`, `retryable: false` | The webhook was deleted, so there is nowhere to send it | Redeliver after you fix a handler that returned `4xx`, or after an outage longer than the retry window. Because the `eventId` is unchanged, your inbox safely skips any delivery you had already accepted. ## Best practices Write the event to your durable inbox, return `2xx`, and do the rest of the work asynchronously. Each attempt times out after 5 seconds, and a timeout triggers a retry and duplicate processing. Retries and redeliveries mean the same payload can arrive more than once. Deduplicate permanently on `eventId`. The same resource can also produce several legitimate events, each with its own `eventId`, so never deduplicate on `id`. Fetch current state and make applying it safe to repeat. See [Idempotency](/guides/idempotency). Events are dispatched concurrently and are **not ordered**. `onramp.completed` can arrive before `onramp.created` for the same on-ramp, and does so most often when the two transitions happen close together. Don't reorder resource events by `occurredAt` either. Key your handler off the resource `id`, fetch current state from the API, and make each handler safe to run regardless of what has already been processed for that resource — including the case where the first event you ever see for a resource is its last one. For `achDebit.*` communication events, atomically ignore a sequence that is not greater than the highest sequence you already accepted for that deposit. This prevents a delayed partial-delivery event from producing a push after full delivery. On receipt, fetch the current resource from the API to get authoritative state rather than relying solely on the event body. Webhooks are notifications, not your only record. Page through the relevant public list endpoint after downtime. For linked-bank deposits, page through `GET /v1/deposits/`; this endpoint is user-scoped, not integrator-scoped, so an integrator-wide recovery must iterate your own user roster and authorize each user's read. For ACH returns, use `GET /v1/integrator/ach-debit/returns`. Then check `GET /v1/integrator/webhooks/deliveries` for `failed` deliveries and [redeliver](#redeliver-a-delivery) them, especially `achDebit.*` communication events. Run this reconciliation at least every 24 hours; delivery payloads are guaranteed for 30 days. Do not synthesize a historical push from current resource state. # Spritz Developer Platform Source: https://docs.spritz.finance/index Spritz is one API for crypto-to-fiat payments (off-ramps, on-ramps, bill pay, cards, and bank rails) with KYC, AML, compliance, and transaction monitoring handled for you. **Stablecoin orchestration for real-world payments.** One API to move money between stablecoins and the traditional financial system: off-ramps, on-ramps, bill pay, cards, and bank rails. Embed crypto-to-fiat payments into your product without building the rails yourself. ## Why Spritz Offering crypto-to-fiat payments on your own means assembling and maintaining a long list of moving parts: banking and payment partners for every rail, KYB and KYC onboarding, AML and sanctions screening, transaction monitoring, flow-of-funds and reconciliation, dispute handling and customer support, and a separate contract, integration, and compliance review for each provider. It can take dozens of contracts and many months before you move a single dollar. Spritz stitches all of that together behind one API. You ship whitelabel crypto and fiat rails inside your own app, without building or owning the compliance and infrastructure underneath. We handle: * **Multiple rails, one integration**: ACH, RTP, wire, SEPA, Faster Payments, EFT, and cards across regions, behind a single API * **Onboarding**: KYC and KYB identity verification for your users * **Compliance**: AML, sanctions screening, and transaction monitoring * **Banking and payment partners**: the partner relationships behind each rail * **Flow of funds**: settlement, reconciliation, and movement of money end to end * **Payment support**: customer support for the payment flows themselves You integrate once and ship. Spritz keeps the rails, partners, and compliance running underneath. ## What you can build Convert and settle between stablecoins and fiat: on-ramps, off-ramps, and auto-converting deposits. Connect and verify the bank accounts where fiat lands. Pay real-world bills directly with stablecoins. Issue and fund spend-anywhere cards backed by stablecoins. Give users non-custodial wallets, with on-chain yield, on the same platform. ## How it works Spritz sits between crypto and the traditional financial system. Your platform calls one REST API; Spritz handles conversion, settlement, compliance, and the underlying bank, card, and blockchain rails. Authenticate your platform and act on behalf of your users. See [Authentication](/guides/authentication). Create and verify the people who move money, then connect their bank accounts or wallets. Convert stablecoins to fiat (or back), pay bills, or fund a card, then track settlement through [webhooks](/guides/webhooks). ## Start here Make your first authenticated API call in minutes. Every endpoint, grouped by domain, with live request examples.