@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.
Installation
npm install @spritz-finance/api-client
# or
yarn add @spritz-finance/api-clientQuick 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 walletTable 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 URLCreating 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 nullThe 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 theterms_acceptancerequirement 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()returns403, 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
- Select an account — choose the bank account, debit card, or bill to pay.
- Create a payment request — specify amount, account ID, and blockchain network.
- Get transaction data — call
getWeb3PaymentParams(EVM) orgetSolanaPaymentParams(Solana). - Execute the blockchain transaction — sign and submit from the user's wallet.
- 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 $3Subsidized 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.01Retrieving 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-Keyheader 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 itsstatusbefore 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 exactlysendTo.amountofsendTo.tokentosendTo.addressbeforesendTo.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 nullGetting 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 | nulloffRampId 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
- Complete platform-level KYC (identity verification)
- Accept the third-party on-ramp provider's Terms of Service
- 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 readThis 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:
- Server: check that the bank deposit option can be offered at all with
client.achDebit.checkEligibility({ email })(see Checking Eligibility) - Server: create a Plaid link token with
client.bankAccount.createLinkToken() - Client: run Plaid Link and send the public token/account IDs back to your server
- Server: complete linking with
client.bankAccount.completeLinking(...) - Server: find an active funding source and fetch limits with
client.fundingSource.getDepositLimits(id) - Server: prepare a quote with
client.deposit.prepare(...) - Client: show the quote and ACH authorization message to the user
- 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 - Server: track the deposit with
client.deposit.get(depositId), or reconcile a backlog by pagingclient.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.
detailis for trusted consumers. The SDK exposes upstream problem details faithfully, includingdetail, 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 createdaccount.updated— account details updatedaccount.deleted— account deleted
Payment Events
payment.created— payment initiatedpayment.updated— payment details updatedpayment.completed— payment completedpayment.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 authorizedonramp.updated— on-ramp status, delivery, or reversal details updatedonramp.completed— on-ramp delivery completed
ACH Debit Return Events
achDebitReturn.created— ACH debit return recordedachDebitReturn.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')
}