npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@spritz-finance/api-client

v0.21.0

Published

Typescript library for interacting with the Spritz Finance API

Readme

@spritz-finance/api-client

TypeScript client for the Spritz Finance API — convert crypto to fiat payments.

NPM

Installation

npm install @spritz-finance/api-client
# or
yarn add @spritz-finance/api-client

Quick Start

import {
    SpritzApiClient,
    Environment,
    PaymentNetwork,
    BankAccountType,
    BankAccountSubType,
} from '@spritz-finance/api-client'

// Initialize with your integration key
const client = SpritzApiClient.initialize({
    environment: Environment.Sandbox,
    integrationKey: 'YOUR_INTEGRATION_KEY_HERE',
})

// Create a user and set their API key
const user = await client.user.create({ email: '[email protected]' })
client.setApiKey(user.apiKey)

// Add a bank account
const bankAccount = await client.bankAccount.create(BankAccountType.USBankAccount, {
    accountNumber: '123456789',
    routingNumber: '987654321',
    name: 'My Checking Account',
    ownedByUser: true,
    subType: BankAccountSubType.Checking,
})

// Create a payment request
const paymentRequest = await client.paymentRequest.create({
    amount: 100,
    accountId: bankAccount.id,
    network: PaymentNetwork.Ethereum,
})

// Get transaction data for the blockchain payment
const transactionData = await client.paymentRequest.getWeb3PaymentParams({
    paymentRequest,
    paymentTokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC
})

// Execute the blockchain transaction from the user's wallet

Table of Contents

Authentication

Spritz uses two levels of authentication:

  • Integration key — identifies your application. Provided by Spritz.
  • User API key — scoped to a single user. Returned when you create a user.

Credentials issued by the Spritz Developer Console also include an integrator secret. Pass it on your backend to sign REST requests with HMAC. Never ship it to a browser or mobile app.

import { SpritzApiClient, Environment } from '@spritz-finance/api-client'

const client = SpritzApiClient.initialize({
    environment: Environment.Sandbox,
    integrationKey: 'YOUR_INTEGRATION_KEY_HERE',
    integratorSecret: process.env.SPRITZ_INTEGRATOR_SECRET, // backend only
    apiKey: 'YOUR_USER_API_KEY_HERE', // omit if no user exists yet
})

After creating a user, set their API key on the client:

client.setApiKey(user.apiKey)

Users

Creating a User

const user = await client.user.create({
  email: '[email protected]',
})

// Response
{
  email: '[email protected]',
  userId: '62d17d3b377dab6c1342136e',
  apiKey: 'ak_ZTBGDcjfdTg3NmYtZDJlZC00ZjYyLThlMDMtZmYwNDJiZDRlMWZm',
}

Creating a user with an email that already exists will throw an error.

With an integrator secret configured, user.create() calls POST /v1/integrator/users on the REST API. A conflict throws a ConflictError (409); branch on its problem.code:

| code | Meaning | What to do | | ------------------------- | --------------------------------------------------------- | --------------------------------- | | USER_ALREADY_EXISTS | The email already has a Spritz account | Connect the existing user (below) | | USER_CREATE_IN_PROGRESS | Another request is creating this user (retryable: true) | Retry shortly |

Without a secret it uses the legacy route, which does not accept Developer Console credentials.

Connecting an Existing User (Spritz Connect)

When user.create() fails with USER_ALREADY_EXISTS, that email already has a Spritz account. Ask the user to authorize your integrator with Spritz Connect. Run these calls on your backend; both need integrator credentials (integrationKey + integratorSecret).

import { hasProblemCode } from '@spritz-finance/api-client'

try {
    return await client.user.create({ email })
} catch (error) {
    if (!hasProblemCode(error, 'USER_ALREADY_EXISTS')) throw error
}

// 1. Start a session for the email that was rejected. Persist `state` with the
//    user's attempt so you can verify the callback.
const { authorizationUrl, sessionId, expiresAt } = await client.connect.createSession({
    redirectUri: 'https://api.example.com/spritz/connect/callback', // registered with Spritz
    state: crypto.randomUUID(),
    email: '[email protected]',
})

// 2. Send the user to `authorizationUrl` unchanged (including its #fragment; don't log it).
//    On iOS/Android open it in the system auth session (ASWebAuthenticationSession /
//    Custom Tabs), never an embedded WebView. The hosted page shows the email, the user
//    signs in to that account and approves.

// 3. Spritz redirects to your redirectUri with ?code=…&state=… (or error=access_denied |
//    server_error | session_expired). Verify `state`, then exchange the code:
const { apiKey, userId, email } = await client.connect.exchangeCode(code)
// Store apiKey server-side only.

redirectUri must exactly match a callback URL Spritz has registered for your integrator; ask your Spritz contact to register it. Sessions expire after 10 minutes and codes are single-use.

Reauthorization

If you need to recover a user's API key (e.g., the user already has a Spritz account, or you've lost access), use the OTP reauthorization flow:

// Request an OTP code sent to the user's email
const { success } = await client.user.requestApiKey('[email protected]')

// Confirm with the OTP code the user provides
const { apiKey, userId, email } = await client.user.authorizeApiKeyWithOTP({
    email: '[email protected]',
    otp: '123456',
})

User Data

const userData = await client.user.getCurrentUser()

REST User Profile

user.getMe() returns the user profile from the REST API (GET /v1/users/me) as-is, typed as UserProfile. It includes the user's verification state and capabilities:

const me = await client.user.getMe()

me.verification.status // 'not_started' | 'verified' | 'failed' | 'disabled' | 'retry' | 'under_review'
me.verification.failureReason // e.g. 'documentary_verification', or null unless failed / retry / under_review
me.verification.provider // 'persona' | 'plaid' — the provider the next verification session will use
me.verification.country // e.g. 'US', or null
me.verification.requirement // outstanding requirement, if any ({ type, status, actionUrl?, retryable? })
me.capabilities // [{ product, method?, name, status, nextRequirement?, requirements }]

status: 'retry' (with an identity_verification requirement whose retryable is true) means a failed verification can be retried by calling verification.createSession() again; under_review means a decision is pending and no new session can be started. This is the REST replacement for getCurrentUser() verification state and retryFailedVerification().

Identity Verification

All users must complete identity verification before using the platform. New users start with a verification status of NotStarted.

The user's verification data is included in the getCurrentUser response, including verification status, verification URL, verified country, and retry capability.

Getting Verification Parameters

const verificationParams = await client.user.getVerificationParams()

// Returns:
// - inquiryId: Unique identifier for this verification inquiry
// - verificationUrl: URL for hosted verification
// - sessionToken: Token for use with Persona's Embedded Flow
// - verificationUrlExpiresAt: Expiration timestamp for the verification URL

Creating a Verification Session (REST)

verification.createSession() creates or resumes the user's verification session via the REST API (POST /v1/users/me/verification-sessions/) and returns the response as-is, typed as VerificationSession:

const { sessionId, provider, sessionToken, verificationUrl, verificationUrlExpiresAt } =
    await client.verification.createSession()

// provider: 'persona' | 'plaid'
// sessionToken: embedded-flow token (Persona session token or Plaid Link token), or null
// verificationUrl: provider-hosted URL, or null

The same call retries a failed verification: when getMe().verification.status is retry, it starts a new inquiry and returns the new session. There is no separate retry endpoint.

Tokens and URLs may expire, so create the session just in time rather than caching it. The call throws a ConflictError (409) with one of VERIFICATION_NOT_RETRYABLE (permanent failure), VERIFICATION_UNDER_REVIEW, VERIFICATION_ALREADY_VERIFIED, or VERIFICATION_SESSION_UNAVAILABLE, a 409 with no code while another session request for the same user is still in progress (retry after it completes), and an InternalServerError (503, with retryAfter seconds in the error body) when the provider is temporarily unavailable.

Option 1: Verification URL

The simplest integration — redirect the user to the hosted verification flow:

const { verificationUrl, verificationUrlExpiresAt } = await client.user.getVerificationParams()

// Open in a browser tab, iframe, or mobile web view.
// The URL is single-use and short-lived. If it expires or the user
// doesn't complete verification, call getVerificationParams() again.

Option 2: Embedded Flow

For full control over the UX, use the inquiryId and sessionToken with Persona's Embedded Flow:

const { inquiryId, sessionToken } = await client.user.getVerificationParams()

// Use inquiryId (and sessionToken if present) with Persona's SDK
// to embed the verification flow directly in your app.

Handling Verification Failures

When verification fails, the verificationMetadata field on the user object provides the failure reason:

| Failure Reason | Description | | -------------------------- | ---------------------------- | | verify_sms | SMS verification failed | | documentary_verification | Document verification failed | | risk_check | Risk assessment failed | | kyc_check | KYC check failed | | watchlist_screening | Watchlist screening failed | | selfie_check | Selfie verification failed | | address_invalid | Invalid address | | duplicate_identity | Identity already exists |

For duplicate_identity failures, matchedEmail indicates whether the duplicate was created through your integration:

const userData = await client.user.getCurrentUser()

if (userData.verificationMetadata?.failureReason === 'duplicate_identity') {
    const matchedEmail = userData.verificationMetadata.details.matchedEmail

    if (matchedEmail) {
        // Duplicate exists within your integration — guide user to their existing account
        console.log(`Already verified as: ${matchedEmail}`)
    } else {
        // Duplicate exists in a different integration (e.g., the main Spritz app)
        console.log('Identity already verified with another Spritz account')
    }
}

Regional Compliance

Some regions require additional fields before their capabilities unlock. In the EEA these are place of birth, nationalities and account purpose.

Checking Requirements

compliance.getRequirements() returns the additional fields the user's region requires (GET /v1/users/me/compliance/requirements), typed as ComplianceRequirements:

const { required, region, complete, deadline, fields } = await client.compliance.getRequirements()

// required: false outside a regulated region, with an empty `fields` array
// region: e.g. 'EEA', or null when nothing is required
// deadline: e.g. '2026-06-15', or null
// fields: [{ field: 'placeOfBirth', status: 'complete' | 'missing' }, ...]

While complete is false, the region's capabilities on user.getMe() carry a regional_compliance requirement.

Submitting Fields

compliance.submit() sends the fields (POST /v1/users/me/compliance). All required fields must be supplied together; a partial submission is rejected with field-level errors on error.problem.errors and nothing is stored.

const { complianceFieldsComplete, bridgeCustomerUpdated } = await client.compliance.submit({
    placeOfBirth: { country: 'DEU', city: 'Berlin' },
    nationalities: ['DEU'],
    accountPurpose: 'personal_or_living_expenses',
})

| Field | Type | Description | | ---------------------- | ------------------- | --------------------------------------------------------------------- | | placeOfBirth.country | string | Country of birth, ISO 3166-1 alpha-3 (e.g. DEU) | | placeOfBirth.city | string (optional) | City of birth. Recommended now, required by EU law from 2027 | | nationalities | string[] | Every nationality the user holds, ISO 3166-1 alpha-3 | | accountPurpose | enum | What the account is for. See SubmitComplianceRequest for the values | | accountPurposeOther | string | Required when accountPurpose is 'other', not accepted otherwise |

bridgeCustomerUpdated is false when the user has not accepted the provider's terms yet. The fields are stored and sent when the provider customer is created, so this is not a failure.

Accepting Terms

While terms are outstanding, the user's capabilities on user.getMe() carry a terms_acceptance requirement whose actionUrl is the provider's hosted flow. That flow produces a signed agreement id; pass it to terms.accept() (POST /v1/users/me/terms):

const { termsAccepted } = await client.terms.accept({
    agreementId, // from the hosted terms flow
    sessionId, // optional fraud-session id from the provider's client SDK
})

agreementId is opaque: the platform resolves which provider it belongs to. This is the REST replacement for the legacy GraphQL onramp.acceptTermsOfService().

Retries: this endpoint does not accept an idempotency key. If a request times out, call user.getMe() and check whether the terms_acceptance requirement is still outstanding before submitting again.

Accounts

Spritz supports four account types: Bank Account, Debit Card, Bill, and Virtual Card. All are referred to as "accounts" within the platform and share common properties (id, type, userId, country, currency, createdAt), with additional fields specific to each type.

Bank Accounts

List

const bankAccounts = await client.bankAccount.list()
// Example response
;[
    {
        id: '62d17d3b377dab6c1342136e',
        name: 'Precious Savings',
        type: 'BankAccount',
        bankAccountType: 'USBankAccount',
        bankAccountSubType: 'Checking',
        userId: '62d17d3b377dab6c1342136e',
        accountNumber: '1234567',
        bankAccountDetails: {
            routingNumber: '00000123',
        },
        country: 'US',
        currency: 'USD',
        email: '[email protected]',
        institution: {
            id: '62d27d4b277dab3c1342126e',
            name: 'Shire Bank',
            logo: 'https://tinyurl.com/shire-bank-logo',
        },
        ownedByUser: true,
        createdAt: '2023-05-03T11:25:02.401Z',
        deliveryMethods: ['STANDARD', 'INSTANT'],
    },
]

Create US Bank Account

import { BankAccountType, BankAccountSubType } from '@spritz-finance/api-client'

const bankAccount = await client.bankAccount.create(BankAccountType.USBankAccount, {
    accountNumber: '123456789',
    routingNumber: '987654321',
    name: 'Precious Savings',
    ownedByUser: true,
    subType: BankAccountSubType.Savings,
})

Input fields:

interface USBankAccountInput {
    accountNumber: string
    routingNumber: string
    subType: BankAccountSubType
    name?: string | null
    email?: string | null
    ownedByUser?: boolean | null
}

Create Canadian Bank Account

import { BankAccountType, BankAccountSubType } from '@spritz-finance/api-client'

const bankAccount = await client.bankAccount.create(BankAccountType.CABankAccount, {
    accountNumber: '123456789',
    transitNumber: '12345',
    institutionNumber: '123',
    name: 'Precious Savings',
    ownedByUser: true,
    subType: BankAccountSubType.Savings,
})

Input fields:

interface CABankAccountInput {
    accountNumber: string
    transitNumber: string
    institutionNumber: string
    name: string
    subType: BankAccountSubType
    email?: string
    ownedByUser?: boolean | null
}

Link a US Bank Account with Plaid

Rather than collecting raw account and routing numbers, link a US bank account through Plaid Link. Plaid verifies account ownership, returns institution metadata, and — for ACH-eligible accounts — provisions the funding source required for ACH onramp.

Plaid Link must be enabled on your integration. It is gated per account. If createLinkToken() returns 403, contact Spritz to enable it, and fall back to Create US Bank Account in the meantime (see Fall back to manual entry below).

Linking is a two-part flow: create a link token on your server, then run the Plaid Link UI on the client. The SDK is server-side only — only the Plaid Link UI runs in the browser.

1. Create a link token (server)

const { linkToken, hostedLinkUrl, expiration } = await client.bankAccount.createLinkToken()

| Field | Type | Description | | --------------- | ---------------- | --------------------------------------------------------- | | linkToken | string | Token for initializing the Plaid Link SDK | | hostedLinkUrl | string \| null | Plaid-hosted linking URL (alternative to running the SDK) | | expiration | string | Token expiry (ISO 8601) |

If a bank uses OAuth, pass the OAuth target as redirectUri (see Handle OAuth redirects):

await client.bankAccount.createLinkToken({
    // Web / iOS: an https:// return URL or iOS universal link
    // Android: your package name (e.g. 'com.example.app')
    redirectUri: 'https://app.example.com/plaid/oauth-return',
})

2. Run Plaid Link (client)

Hand the linkToken to the Plaid Link SDK (React Native, Web, iOS, Android). On success, send the public token and selected account IDs back to your server.

React Native (react-native-plaid-link-sdk v11+):

import { create, open } from 'react-native-plaid-link-sdk'

create({
    token: linkToken,
    onSuccess: async (success) => {
        await yourServer.completePlaidLink({
            publicToken: success.publicToken,
            accountIds: success.metadata.accounts.map((a) => a.id),
            institutionId: success.metadata.institution?.id,
            institutionName: success.metadata.institution?.name,
        })
    },
    onExit: (exit) => {
        if (exit.error) console.error('Plaid error:', exit.error)
    },
})

open()

Web (react-plaid-link or the vanilla JS SDK):

const handler = Plaid.create({
    token: linkToken,
    onSuccess: async (publicToken, metadata) => {
        await yourServer.completePlaidLink({
            publicToken,
            accountIds: metadata.accounts.map((a) => a.id),
            // Web SDK uses institution_id; the RN SDK uses id
            institutionId: metadata.institution?.institution_id,
            institutionName: metadata.institution?.name,
        })
    },
})
handler.open()

3. Complete linking (server)

const { bankAccounts } = await client.bankAccount.completeLinking({
    publicToken,
    accountIds,
    institutionId,
    institutionName,
})

// A funding source is provisioned for ACH-eligible accounts. Its presence is
// the signal that the account can be used for ACH onramp.
const onrampable = bankAccounts.find((b) => b.fundingSourceId)

completeLinking exchanges the public token, stores the linked bank account(s), and provisions a funding source for ACH-eligible accounts. A null fundingSourceId means the account is usable for off-ramp only. See the ACH Onramp Integration Guide for the bank-account-vs-funding-source model.

Handle OAuth redirects

Some institutions send the user out to their bank's OAuth page and redirect back when auth completes. Because Spritz creates the link token, the redirect targets must be allowlisted on Spritz's Plaid account — send them to Spritz before going live:

| Platform | What to register | | -------- | ------------------------------------------------------------------------------------------------------- | | Web | An HTTPS return URL on a domain you control (e.g. https://app.example.com/plaid/oauth-return) | | iOS | The universal link URL you receive the redirect on — custom URL schemes (yourapp://) are not accepted | | Android | Your app's package name (e.g. com.example.app) |

Pass the same value as redirectUri when creating the link token. The client-side work to resume the flow differs per platform (web requires re-initializing Link with receivedRedirectUri; native iOS/Android forward the redirect into the in-memory SDK). See Handle OAuth redirects in the ACH Onramp guide for the full per-platform breakdown.

Fall back to manual entry

Plaid Link can be unavailable — it may not be enabled on your integration yet, createLinkToken() can fail, or the user may abandon or hit an error in the Link UI. Treat manual account/routing entry as a fallback so users can always add a bank account:

import { BankAccountType, BankAccountSubType } from '@spritz-finance/api-client'

try {
    const { linkToken } = await client.bankAccount.createLinkToken()
    // hand linkToken to Plaid Link on the client, then completeLinking(...)
} catch {
    // Plaid unavailable — collect account + routing numbers and create directly
    const bankAccount = await client.bankAccount.create(BankAccountType.USBankAccount, {
        accountNumber: '123456789',
        routingNumber: '987654321',
        subType: BankAccountSubType.Checking,
        ownedByUser: true,
    })
}

A manually added account is immediately usable for off-ramp. ACH on-ramp additionally requires a funding source, and the Plaid link flow is what provisions it — so prefer Plaid Link whenever ACH onramp is in scope, and use manual entry as the off-ramp fallback.

Debit Cards

Supported networks: Visa and Mastercard.

List

const debitCards = await client.debitCard.list()
// Example response
;[
    {
        id: '62d17d3b377dab6c1342136e',
        type: 'DebitCard',
        name: 'My Visa Debit',
        userId: '62d17d3b377dab6c1342136e',
        country: 'US',
        currency: 'USD',
        payable: true,
        debitCardNetwork: 'Visa',
        expirationDate: '12/25',
        cardNumber: '4111111111111111',
        mask: '1111',
        createdAt: '2023-01-01T00:00:00Z',
        paymentCount: 5,
        externalId: 'ext-123',
    },
]

Create

const debitCard = await client.debitCard.create({
    cardNumber: '4111111111111111', // 13-19 digits
    expirationDate: '12/25', // MM/YY
    name: 'My Visa Debit', // optional
})

Bills

List

const bills = await client.bill.list()
// Example response
;[
    {
        id: '62d17d3b377dab6c1342136e',
        name: 'Precious Credit Card',
        type: 'Bill',
        billType: 'CreditCard',
        userId: '62d17d3b377dab6c1342136e',
        mask: '4567',
        originator: 'User',
        payable: true,
        verifying: false,
        billAccountDetails: {
            balance: 240.23,
            amountDue: 28.34,
            openedAt: '2023-05-03T11:25:02.401Z',
            lastPaymentAmount: null,
            lastPaymentDate: null,
            nextPaymentDueDate: '2023-06-03T11:25:02.401Z',
            nextPaymentMinimumAmount: 28.34,
            lastStatementBalance: 180.23,
            remainingStatementBalance: null,
        },
        country: 'US',
        currency: 'USD',
        dataSync: {
            lastSync: '2023-05-03T11:25:02.401Z',
            syncStatus: 'Active',
        },
        institution: {
            id: '62d27d4b277dab3c1342126e',
            name: 'Shire Bank Credit Card',
            logo: 'https://tinyurl.com/shire-bank-logo',
        },
        createdAt: '2023-05-03T11:25:02.401Z',
        deliveryMethods: ['STANDARD'],
    },
]

Create

Adding a bill requires the institution ID and the account number:

import { BillType } from '@spritz-finance/api-client'

const institutions = await client.institution.popularUSBillInstitutions(BillType.CreditCard)
const bill = await client.bill.create(institutions[0].id, '12345678913213', BillType.CreditCard)

Finding Bill Institutions

// Popular institutions (optionally filtered by bill type)
const popular = await client.institution.popularUSBillInstitutions()
const mortgages = await client.institution.popularUSBillInstitutions(BillType.Mortgage)

// Search by name
const results = await client.institution.searchUSBillInstitutions('american express')
const filtered = await client.institution.searchUSBillInstitutions(
    'american express',
    BillType.CreditCard
)

Virtual Cards

Virtual cards are crypto-funded payment cards.

Fetch

Returns card details excluding sensitive fields (card number, CVV):

const virtualCard = await client.virtualCard.fetch()
// Example response
{
  id: '62d17d3b377dab6c1342136e',
  type: 'VirtualCard',
  virtualCardType: 'USVirtualDebitCard',
  userId: '62d17d3b377dab6c1342136e',
  mask: '0001',
  country: 'US',
  currency: 'USD',
  balance: 0,
  renderSecret: 'U2FsdGVkX18bLYGYLILf4AeW5fOl8VYxAvKWVDtbZI5DO7swFqkJ2o',
  billingInfo: {
    holder: 'Bilbo Baggins',
    phone: '+123456789',
    email: '[email protected]',
    address: {
      street: '1 Bagshot Row',
      street2: '',
      city: 'Hobbiton',
      subdivision: 'The Shire',
      postalCode: '12345',
      countryCode: 'ME',
    },
  },
}

Create

import { VirtualCardType } from '@spritz-finance/api-client'

const virtualCard = await client.virtualCard.create(VirtualCardType.USVirtualDebitCard)

Displaying Sensitive Card Details

To render the full card number and CVV, use the renderSecret from the fetch response with one of the Spritz secure element libraries:

Address Book

Each account is allocated a unique on-chain payment address per network. Tokens sent to these addresses are automatically credited to the account. Accepted tokens vary by network — generally USDC and USDT at minimum.

// Included in account responses
{
  paymentAddresses: [
    { network: 'ethereum', address: '0xc0ffee254729296a45a3885639AC7E10F9d54979' },
    { network: 'polygon', address: '0xc0ffee254729296a45a3885639AC7E10F9d54979' },
  ],
}

Renaming Accounts

await client.bankAccount.rename('account-id', 'New Name')
await client.debitCard.rename('card-id', 'New Name')
await client.bill.rename('bill-id', 'New Name')

Deleting Accounts

await client.bankAccount.delete('account-id')
await client.debitCard.delete('card-id')
await client.bill.delete('bill-id')

Payments (Off-ramp)

Payment Flow

  1. Select an account — choose the bank account, debit card, or bill to pay.
  2. Create a payment request — specify amount, account ID, and blockchain network.
  3. Get transaction data — call getWeb3PaymentParams (EVM) or getSolanaPaymentParams (Solana).
  4. Execute the blockchain transaction — sign and submit from the user's wallet.
  5. Check payment status — query the resulting fiat payment.

Your application needs a connection to the user's wallet to sign transactions. If you don't have one, consider Web3Modal or Web3-Onboard.

Creating a Payment Request

import { PaymentNetwork, AmountMode } from '@spritz-finance/api-client'

const paymentRequest = await client.paymentRequest.create({
    amount: 100,
    accountId: account.id,
    network: PaymentNetwork.Ethereum,
    deliveryMethod: 'INSTANT', // optional
    amountMode: AmountMode.TOTAL_AMOUNT, // optional, defaults to AMOUNT_RECEIVED
})
// Example response
{
  id: '645399c8c1ac408007b12273',
  userId: '63d12d3B577fab6c6382136e',
  accountId: '6322445f10d3f4d19c4d72fe',
  status: 'CREATED',
  amount: 100,
  feeAmount: 0,
  amountDue: 100,
  network: 'ethereum',
  createdAt: '2023-05-04T11:40:56.488Z',
}

Amount Mode

  • AMOUNT_RECEIVED (default) — the recipient receives the specified amount; fees are added on top.
  • TOTAL_AMOUNT — the specified amount includes fees; the recipient receives less.

Fee Subsidies

Integrators can subsidize transaction fees on behalf of users. This is a gated feature — contact Spritz to enable it.

const paymentRequest = await client.paymentRequest.create({
    amount: 100,
    accountId: account.id,
    network: PaymentNetwork.Ethereum,
    feeSubsidyPercentage: '100', // percentage of fee to cover
    maxFeeSubsidyAmount: '5', // cap per transaction in USD
})

// Fee = $3 → integrator pays $3, user pays $0
// Fee = $8 → integrator pays $5, user pays $3

Subsidized amounts are invoiced to the integrator separately.

Fulfilling a Payment — EVM

For EVM networks, you interact with the SpritzPay smart contract (deployment addresses):

const transactionData = await client.paymentRequest.getWeb3PaymentParams({
  paymentRequest,
  paymentTokenAddress: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC
})

// Example response
{
  contractAddress: '0xbF7Abc15f00a8C2d6b13A952c58d12b7c194A8D0',
  method: 'payWithToken',
  calldata: '0xd71d9632...',
  value: null,
  requiredTokenInput: '100000000',
}

Use contractAddress as to, calldata as data, and value to build the transaction. Check requiredTokenInput against the user's balance before submitting.

Fulfilling a Payment — Solana

const transactionData = await client.paymentRequest.getSolanaPaymentParams({
  paymentRequest,
  paymentTokenAddress: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', // USDC
  signer: 'YourSolanaWalletAddress',
})

// Example response
{
  versionedTransaction: VersionedTransaction, // ready to sign
  transactionSerialized: 'base64...',          // base64-encoded alternative
}

Transaction Fees

Fees apply once monthly volume exceeds $100. To check the fee for a given amount:

const fee = await client.paymentRequest.transactionPrice(101)
// Returns: 0.01

Retrieving Payments

Payments are created once a payment request reaches Confirmed status.

// By payment ID
const payment = await client.payment.fetchById('6368e3a3ec516e9572bbd23b')

// By payment request ID
const payment = await client.payment.getForPaymentRequest(paymentRequest.id)

// All payments for an account
const payments = await client.payment.listForAccount(account.id)
// Example response
{
  id: '6368e3a3ec516e9572bbd23b',
  userId: '63d12d3B577fab6c6382136e',
  status: 'COMPLETED',
  accountId: '6322445f10d3f4d19c4d72fe',
  amount: 100,
  feeAmount: null,
  createdAt: '2022-11-07T10:53:23.998Z',
  transaction: {
    hash: '0x1234...abcdef',
    from: '0xYourWalletAddress',
    asset: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
    value: 100000000,
    network: 'ethereum',
  },
}

Payment Limits

const limits = await client.payment.getPaymentLimits(account.id)

// Example response
{
  perTransaction: 20000,
  dailyRemainingVolume: 150000,
}

Refunding a Payment

A failed off-ramp payment can be refunded, either by returning the funds to the user's Spritz balance or by reissuing the payout to a bank account.

// Reissue the payout to a different bank account
await client.offramp.refund(offRampId, {
    method: 'account',
    accountId: '6a5f75585a936eb477232f05',
})

// Reissue to the off-ramp's original destination account
await client.offramp.refund(offRampId, { method: 'account' })

// Return the funds to the user's Spritz balance
await client.offramp.refund(offRampId, { method: 'credit' })

| Field | Type | Description | | ----------- | ----------------------- | ---------------------------------------------------------------------------------------- | | method | 'account' \| 'credit' | account reissues the payout to a bank account; credit returns funds to the balance | | accountId | string (optional) | Only with method: 'account'. Omit to reuse the off-ramp's original destination account |

Only failed off-ramps settled through Modern Treasury or Checkbook are refundable — anything else is rejected. The response is the updated off-ramp record, with status moving to refunded.

Retries: the platform recommends an Idempotency-Key header so a retried request replays the original response rather than returning a stale "not refundable" error. The client does not currently send one, so if a refund request times out, re-fetch the off-ramp and check its status before issuing another.

Off-ramp Quotes

Off-ramp quotes are the REST flow for converting crypto to fiat, including to EUR destinations: create a quote, fulfil it on-chain, then follow the quote until its off-ramp is created. For new integrations use client.offRampQuote; the payment-request flow above is the legacy GraphQL flow.

Creating a Quote

const quote = await client.offRampQuote.create({
    accountId: bankAccount.id,
    amount: '100.00',
    amountMode: 'input',
    chain: 'base',
    tokenAddress: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
})

| Field | Type | Description | | -------------- | --------------------- | ----------------------------------------------------------------------------------------------------- | | accountId | string | Destination account ID | | amount | string | Destination fiat amount in output mode, or USD collected in input mode | | amountMode | 'output' \| 'input' | Optional, defaults to output. EUR destinations require input | | chain | enum | Chain the crypto is sent on | | tokenAddress | string | Token contract address. Optional in the type, but required on every chain except Bitcoin and Dash | | rail | enum (optional) | Payout rail, e.g. sepa | | memo | string (optional) | Payment note, bank account payouts only |

Check quote.fulfillment for the next step:

  • sign_transaction: get the transaction to sign, sign it and broadcast it.
  • send_to_address: send exactly sendTo.amount of sendTo.token to sendTo.address before sendTo.expiresAt.

sendTo is typed as nullable, and checking fulfillment does not narrow it, so check it too:

if (quote.fulfillment === 'send_to_address' && quote.sendTo) {
    const { address, amount, token, expiresAt } = quote.sendTo
    // send exactly `amount` of `token` to `address` before `expiresAt`
}

With amountMode: 'input', quote.input.amount is the exact USD value collected and quote.output.amount is an estimate (quote.output.estimated is true). The settled amount is reported by the off-ramp.

Retries: this endpoint does not support an idempotency key. A retry creates a second quote. A quote that is never funded ends as expired.

Retrieving a Quote

const quote = await client.offRampQuote.get(quoteId)

quote.status // 'created' | 'transaction_pending' | 'confirmed' | 'completed' | 'expired' | ...
quote.confirmation // { transactionHash, explorerUrl } once the transaction is detected, else null
quote.offRampId // the off-ramp (fiat leg) once it is created, else null

Getting the Transaction to Sign

For a sign_transaction quote, fetch the transaction and branch on type.

EVM: build the transaction from the contract call.

const transaction = await client.offRampQuote.getTransaction(quote.id)

if (transaction.type === 'evm') {
    await walletClient.sendTransaction({
        to: transaction.contractAddress,
        data: transaction.calldata,
        value: transaction.value ? BigInt(transaction.value) : undefined,
    })
}

Solana: the transaction is built for senderAddress, so pass it. feePayer is optional and defaults to senderAddress.

const transaction = await client.offRampQuote.getTransaction(quote.id, {
    senderAddress: wallet.publicKey.toBase58(),
})

if (transaction.type === 'solana') {
    const tx = VersionedTransaction.deserialize(
        Buffer.from(transaction.transactionSerialized, 'base64')
    )
    // sign and send tx
}

Sui: pass senderAddress too, then restore the bytes with Transaction.from(transaction.transactionSerialized) from @mysten/sui/transactions, sign and execute.

Without senderAddress, Solana and Sui quotes are rejected with a BadRequestError (problem code sender_address_required). A send_to_address quote (Bitcoin, Dash, Tron) has no transaction to sign and is rejected with an UnprocessableEntityError.

Reporting the Transaction

Optional. After broadcasting, report the transaction hash so tracking starts immediately instead of when the chain watcher notices it. This works for both fulfillment types: the transaction you signed, or the transfer you sent to sendTo.address.

const quote = await client.offRampQuote.submit(quoteId, {
    transactionHash: '0x5c504ed432cb51138bcf09aa5e8a410dd4a1e204ef84bfed1be16dfba1b22060',
})

quote.status // 'transaction_pending'
quote.offRampId // string | null

offRampId can still be null in this response: the generated type allows it, and the field is documented as set once the crypto payment confirms. Handle null by reading the quote again later with offRampQuote.get(), or by waiting for the payment.created webhook. confirmed still comes from the chain.

Retries: reporting the same hash again is safe, including after a timeout. Reporting a different hash once one is on record throws a BadRequestError.

On-ramp

The on-ramp feature allows users to purchase crypto stablecoins via ACH or wire transfer.

Prerequisites

  1. Complete platform-level KYC (identity verification)
  2. Accept the third-party on-ramp provider's Terms of Service
  3. Provider KYC processes automatically after ToS acceptance

Checking User Access

const access = await client.user.getUserAccess()

// Off-ramp capabilities
if (access.capabilities.offramp.active) {
    console.log('Off-ramp features:', access.capabilities.offramp.features)
    // US: 'us_bank_account', 'us_debit_card'
    // CA: 'ca_bank_account'
}

// On-ramp capabilities
if (access.capabilities.onramp.active) {
    console.log('On-ramp features:', access.capabilities.onramp.features)
    // May include: 'ach_purchase', 'wire_purchase'
} else {
    for (const req of access.capabilities.onramp.requirements) {
        console.log(`${req.type}: ${req.description}`)
    }
}

Activation Steps

1. Complete Platform KYC

const access = await client.user.getUserAccess()

if (!access.kycStatus.verified) {
    if (access.kycRequirement?.actionUrl) {
        console.log('Complete KYC at:', access.kycRequirement.actionUrl)
    }
    if (access.kycRequirement?.status === 'failed' && access.kycRequirement.retryable) {
        await client.user.retryFailedVerification()
    }
}

2. Accept Terms of Service

const access = await client.user.getUserAccess()
const tosRequirement = access.capabilities.onramp.requirements.find(
    (req) => req.type === 'terms_acceptance'
)

if (tosRequirement?.actionUrl) {
    // Display tosRequirement.actionUrl in a browser tab, iframe, or webview.
    // Listen for the signedAgreementId via postMessage:
    window.addEventListener('message', (event) => {
        if (event.data.signedAgreementId) {
            await client.onramp.acceptTermsOfService(event.data.signedAgreementId)
        }
    })
}

3. Wait for Provider KYC

Provider KYC runs automatically after ToS acceptance. No action required — monitor the status:

const access = await client.user.getUserAccess()
const kycReq = access.capabilities.onramp.requirements.find(
    (req) => req.type === 'identity_verification'
)

// kycReq is undefined when complete, otherwise check kycReq.status ('pending' | 'failed')

Use the capabilities.updated webhook event to be notified when the user's capabilities change.

Virtual Accounts

Once on-ramp is active, users can create virtual accounts to receive fiat deposits:

import { PaymentNetwork, onrampSupportedTokens } from '@spritz-finance/api-client'

// Check supported tokens for a network
const tokens = onrampSupportedTokens[PaymentNetwork.Ethereum]
// ['USDC', 'USDT', 'DAI', 'USDP', 'PYUSD']

// Create a virtual account
const virtualAccount = await client.virtualAccounts.create({
    network: PaymentNetwork.Ethereum,
    address: '0xYourEthereumAddress',
    token: 'USDC',
})

// Deposit instructions for funding via ACH/wire
const { bankName, bankAccountNumber, bankRoutingNumber, bankAddress } =
    virtualAccount.depositInstructions

// List all virtual accounts
const accounts = await client.virtualAccounts.list()

Auto-ramp Accounts

An auto-ramp account is a virtual bank account in the user's name: fiat deposited into it is converted to a token and sent to a wallet address. client.autoRampAccount is the REST replacement for the legacy GraphQL client.virtualAccounts above, and the one to use for EUR (SEPA) accounts.

Listing Accounts

const accounts = await client.autoRampAccount.list()

// Example response
[
    {
        id: '507f1f77bcf86cd799439011',
        depositInstructions: {
            type: 'iban',
            bankName: 'Example Bank',
            bankAddress: '1 Example Street, Berlin',
            paymentRails: ['sepa'],
            iban: 'DE89370400440532013000',
            bic: 'COBADEFFXXX',
        },
        network: 'solana',
        address: '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d',
        token: 'USDC',
        currency: 'EUR',
        status: 'active',
        createdAt: '2026-01-15T10:30:00.000Z',
    },
]

Branch on depositInstructions.type: us carries bankRoutingNumber and bankAccountNumber, iban carries iban and an optional bic.

Getting an Account

const account = await client.autoRampAccount.get(accountId)

Creating an Account

const account = await client.autoRampAccount.create({
    address: '5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d',
    network: 'solana',
    token: 'USDC',
})

The address must be valid for the network, and the network and token combination must be supported for the user's region (see GET /v1/on-ramps/supported-pairs); otherwise the call throws a BadRequestError whose problem.field is address or network. Confirm account.status is active before showing the deposit instructions to a user.

Retries: this endpoint does not support an idempotency key. After a timeout, call autoRampAccount.list() and look for the account before creating it again.

Estimating a Deposit

const estimate = await client.autoRampAccount.estimate(accountId, '2525.00')

estimate.fees.total // every expected fee, in the account's currency (EUR on SEPA accounts)
estimate.fees.maximum // upper bound on the total, when there is enough history
estimate.output.amount // crypto expected at the destination
estimate.output.minimum // lower bound on the amount, when there is enough history
estimate.rate.asOf // when the rate was read

This is an estimate, not a quote: no rate is locked. The fee belongs to the account, so do not reuse an estimate across accounts.

fees.maximum and output.minimum are omitted until enough deposits have settled on the network (fees.networkFeeSamples says how many). Do not promise a user a ceiling or a floor when they are absent.

When no estimate can be produced, branch on the problem code:

import { hasProblemCode } from '@spritz-finance/api-client'

try {
    await client.autoRampAccount.estimate(accountId, '2525.00')
} catch (error) {
    if (hasProblemCode(error, 'rate_unavailable')) {
        // 503, transient: retry
    } else if (hasProblemCode(error, 'unsupported_currency_pair')) {
        // 400, permanent
    } else if (hasProblemCode(error, 'exchange_rate_provider_error')) {
        // 502, the rate provider failed
    }
}

Supported Tokens

| Network | Tokens | | --------- | ---------------------------- | | Ethereum | USDC, USDT, DAI, USDP, PYUSD | | Polygon | USDC | | Base | USDC | | Arbitrum | USDC | | Avalanche | USDC | | Optimism | USDC | | Solana | USDC, PYUSD | | Tron | USDT |

ACH Onramp (Direct Debit)

ACH onramp lets users convert USD from their bank account into USDC delivered to a Solana wallet. The integration is a short server-side flow with one client-side Plaid step:

  1. Server: check that the bank deposit option can be offered at all with client.achDebit.checkEligibility({ email }) (see Checking Eligibility)
  2. Server: create a Plaid link token with client.bankAccount.createLinkToken()
  3. Client: run Plaid Link and send the public token/account IDs back to your server
  4. Server: complete linking with client.bankAccount.completeLinking(...)
  5. Server: find an active funding source and fetch limits with client.fundingSource.getDepositLimits(id)
  6. Server: prepare a quote with client.deposit.prepare(...)
  7. Client: show the quote and ACH authorization message to the user
  8. Server: create the deposit with client.deposit.create(input, { idempotencyKey }); Spritz runs risk checks before initiating the ACH pull. Persist one unique key per deposit intent and reuse it verbatim on retries so a timed-out request replays the original response instead of authorizing a second debit
  9. Server: track the deposit with client.deposit.get(depositId), or reconcile a backlog by paging client.deposit.list({ limit, cursor })
// Page the authenticated user's deposits, newest first
const { data, hasMore, nextCursor } = await client.deposit.list({ limit: 25 })

if (hasMore && nextCursor) {
    const next = await client.deposit.list({ limit: 25, cursor: nextCursor })
}

// Read one deposit's ACH debit and crypto release state
const deposit = await client.deposit.get('dep_01JV7Q8M4Y8K6N2Z5P3R1T9W0X')
console.log(deposit.status, deposit.debitStatus, deposit.releaseStatus)

client.deposit.list() is user-scoped, so an integrator-wide reconciliation iterates your own user roster and authorizes each user's read with client.setApiKey(userApiKey). Webhooks are notifications, not the only record of deposits.

Authorization is derived from the verified ACH funding source — no wallet signature is required.

A backend create must send clientIp — the public address your edge observed for the authorizing client, distinct from your backend's own address. The generated type marks it optional because the contract allows omitting it only for a direct client submission authenticated with a submissionToken, which this SDK does not send; a backend create without it fails at runtime, not at compile time.

const deposit = await client.deposit.create(
    { preparationId: preparation.preparationId, clientIp: req.ip },
    { idempotencyKey }
)

To move submission onto the customer's device, pass clientNetwork: { ipAddresses: [req.ip] } to prepare and forward the returned submissionToken to that client. The client then calls POST /v1/deposits/direct itself with Authorization: Bearer ach_submit_... as its only credential — no user API key, no integrator key, no HMAC headers — plus the Idempotency-Key header and a body of just { preparationId }. This SDK signs every REST call with integrator HMAC, so that request should not go through it; see the ACH Onramp Integration Guide for the full request.

prepare also accepts an optional customerContext object (at most 8192 encoded UTF-8 bytes) carrying partner-side context for later fraud analysis. Its field mapping is agreed privately per integration; missing, unknown or invalid fields never block preparation. Never send credentials or bank data in it.

If risk checks block the create step, the API returns 409 before any ACH debit is pulled. Prepare a new quote before retrying; blocked create attempts consume the original preparationId.

For a complete walkthrough with code examples, request/response schemas, and deposit lifecycle documentation, see the ACH Onramp Integration Guide.

A standalone sandbox demo is available at scripts/sandbox/ach-onramp.html. Run yarn build && node scripts/sandbox/evidence-server.mjs, then open http://localhost:3001/ach-onramp.html to test the SDK-backed flow and save redacted QC evidence.

Checking Eligibility

Before you show a bank deposit option to someone who is not yet a Spritz user, ask whether it is available for their email address:

const { eligible } = await client.achDebit.checkEligibility({ email: '[email protected]' })

if (eligible) {
    // Offer the bank deposit option
}

This route authenticates as the integrator over HMAC and takes no user bearer key, because the address it asks about need not belong to an existing user yet.

Eligibility only moves in one direction — once an address is eligible it stays eligible — so eligible: false may be transient. Re-check it rather than caching the negative against the address.

Once the user exists, stop asking: the capabilities on client.user.getUserAccess() become the source of truth for whether the option is available to them.

Error Handling

Every non-2xx REST response throws an APIError subclass chosen by status (BadRequestError, AuthenticationError, PermissionDeniedError, NotFoundError, ConflictError, UnprocessableEntityError, RateLimitError, InternalServerError). Transport failures throw APIConnectionError, and a timeout throws APIConnectionTimeoutError.

When the response carries an RFC 9457 problem body, it is parsed onto error.problem as typed ProblemDetails. Branch on the problem type or code rather than on the status, which is rarely specific enough:

import { hasProblemType } from '@spritz-finance/api-client'

try {
    await client.deposit.create(input, options)
} catch (error) {
    if (hasProblemType(error, 'urn:problem-type:idempotency-conflict')) {
        // The same idempotency key was used with a different request body.
    }

    throw error
}

hasProblemType and hasProblemCode are type guards: inside the branch, error is narrowed to an APIError whose problem.type (or problem.code) is the literal you passed. isAPIError(error) narrows without checking either.

ProblemDetails

| Field | Type | Notes | | ----------------- | ------------------------------------------------------- | ---------------------------------------------------------------- | | type | string | Problem type URI — the stable thing to branch on | | title | string | Short summary of the problem type | | status | number | HTTP status restated in the body | | detail | string | Human-facing explanation of this occurrence | | instance | string | URI for this specific occurrence | | code | string | Machine-readable cause, when exactly one thing failed | | field | string | The offending request field, alongside code | | errors | Array<{ field, message, code? }> | Field-level causes, when more than one thing failed | | retryable | boolean | Whether the same request may succeed later | | retryAfter | number | Seconds to wait before retrying | | suggestedAction | 'auto_ramp' \| 'wait_for_settlement' \| (string & {}) | What the API suggests doing next; open so new values still parse | | clearsAt | string \| null | When a limit clears; null means not bounded by time | | availableAt | string \| null | When the resource becomes available | | permanent | boolean | Whether retrying can ever succeed | | realm | string | Authentication realm, on some 401s | | scope | string | Scope required for the resource, on some 401s | | resourceType | string | Type of the missing resource; required on 404s | | resourceId | string | Id of the missing resource; required on 404s |

Every field is optional. The payload is untrusted, so a field appears only when the response carried it with its documented type — anything malformed is dropped, inherited properties are ignored, and a malformed body never turns into a thrown parse error. problem itself is undefined for transport failures, non-JSON bodies, and payloads with nothing documented in them.

Some fields only appear on certain problems — realm/scope on some 401s, resourceType/resourceId on 404s — so check before reading them:

if (isAPIError(error) && error.status === 404) {
    logger.warn(`missing ${error.problem?.resourceType}: ${error.problem?.resourceId}`)
}

The original parsed payload is always preserved on error.error, so fields ProblemDetails does not model stay reachable for logging:

if (isAPIError(error)) {
    logger.warn({
        status: error.status,
        problem: error.problem,
        payload: error.error, // untouched, including fields not modelled above
        requestId: error.requestId,
        traceId: error.traceId,
    })
}

requestId and traceId are lifted onto the error from the x-amzn-requestid and x-amzn-trace-id response headers, and remain available under error.headers.

detail is for trusted consumers. The SDK exposes upstream problem details faithfully, including detail, which is written for the integrator rather than for an end user. Decide what is safe to forward at your own frontend boundary — the SDK does not make that call for you.

Sandbox

Use Environment.Sandbox for development and testing. The sandbox environment is available at https://sandbox.spritz.finance.

Bypassing KYC

In sandbox, you can skip identity verification to speed up testing:

// Simulate successful US KYC verification
await client.sandbox.bypassKyc()

// Simulate KYC for a specific country
await client.sandbox.bypassKyc({ country: 'CA' })

// Simulate a failed KYC check
await client.sandbox.bypassKyc({ failed: true })

This endpoint returns 403 in production.

Simulating an Auto-ramp Deposit

The provider's sandbox cannot credit a virtual account, so this is the only way to make an auto-ramp account settle in sandbox:

const { onRampId, depositId, status } = await client.sandbox.simulateAutoRampDeposit(accountId, {
    amount: '2525.00', // in the account's currency
    gasFee: '4.20', // optional, defaults to '0.00'
    exchangeFee: '2.53', // optional; otherwise derived from the provider's live spread
    settle: false, // optional, leaves the deposit at 'processing'
})

// Advance the same on-ramp
await client.sandbox.simulateAutoRampDeposit(accountId, { amount: '2525.00', depositId })

const onRamp = await client.onrampPayment.get(onRampId)

The on-ramp it produces is an ordinary one: it appears in onrampPayment.list(), fires the same onramp.* webhooks and carries the same fee breakdown. This endpoint returns 403 in production.

Webhooks

Events

Account Events

  • account.created — new account created
  • account.updated — account details updated
  • account.deleted — account deleted

Payment Events

  • payment.created — payment initiated
  • payment.updated — payment details updated
  • payment.completed — payment completed
  • payment.refunded — payment refunded

Verification Events

  • verification.status.updated — user verification status changed

Capability Events

  • capabilities.updated — user capabilities changed

On-Ramp Events

  • onramp.created — on-ramp record created after a deposit is authorized
  • onramp.updated — on-ramp status, delivery, or reversal details updated
  • onramp.completed — on-ramp delivery completed

ACH Debit Return Events

  • achDebitReturn.created — ACH debit return recorded
  • achDebitReturn.updated — ACH debit return details updated

Use '*' to subscribe a webhook endpoint to all current and future webhook events.

Setup

const webhook = await client.webhook.create({
    url: 'https://my.webhook.url/spritz',
    events: ['onramp.created', 'onramp.updated', 'achDebitReturn.created'],
})

// Subscribe to all events
await client.webhook.create({
    url: 'https://my.webhook.url/spritz/all',
    events: ['*'],
})

Webhook payloads have the following shape:

{
    "userId": "user-id",
    "id": "resource-id",
    "eventName": "event-name"
}

Management

// List all webhooks
const webhooks = await client.webhook.list()

// Update a webhook's event subscriptions
await client.webhook.update('webhook-id', {
    events: ['onramp.updated', 'achDebitReturn.created', 'achDebitReturn.updated'],
})

// Delete a webhook
await client.webhook.delete('webhook-id')

Security and Signing

Webhook requests are signed with HMAC SHA256 using your webhook secret. The signature is sent in the Signature HTTP header. Verify the signature against the raw request body before parsing JSON.

Setting a Webhook Secret

await client.webhook.updateWebhookSecret('your-secret')

Verifying Signatures

import { createHmac, timingSafeEqual } from 'node:crypto'

function verifySpritzWebhook(rawBody: string, signature: string, secret: string) {
    const expected = createHmac('sha256', secret).update(rawBody).digest('hex')
    const expectedBuffer = Buffer.from(expected, 'utf8')
    const signatureBuffer = Buffer.from(signature, 'utf8')

    if (expectedBuffer.length !== signatureBuffer.length) return false
    return timingSafeEqual(expectedBuffer, signatureBuffer)
}

const signature = request.headers['signature']
if (!signature || !verifySpritzWebhook(rawBody, signature, WEBHOOK_SECRET)) {
    throw new Error('Invalid webhook signature')
}