Sending Payments
Balances, quotes, network fees and the four payment statuses.
POST /v1/quotesuserOpHashpasskeyDatasignaturerecipientAddresstransaction.*1. Check the balance
List wallet balances returns every wallet of a customer with its balances.
2. Get a quote
Get fee quote returns the network fee and the maximum amount the wallet can send. Do not send the sender's address: we find the wallet from moduleId and the x-customer-id header, and return the one we priced.
Amounts are in the asset's display unit, for example "12.5" USD₮ or "0.001" BTC, never in base units.
3. Send
Every payment is approved with the customer's passkey. The flow depends on the network.
EVM (evm_erc4337)
- Prepare userOp with the chain, token, recipient and amount. It returns a
userOpHash. - Have the customer sign the
userOpHashwith their passkey. - Submit userOp signature. The payment is broadcast once the wallet's signing threshold is met; for most wallets that is one signature.
Example: approving an EVM payment
In the browser, turn the userOpHash into the passkey challenge and ask the customer to approve:
import { startAuthentication } from '@simplewebauthn/browser';
function hashToChallenge(userOpHash: string) {
const bytes = userOpHash
.slice(2)
.match(/../g)!
.map(h => parseInt(h, 16));
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
const challenge = hashToChallenge(userOpHash);
const assertion = await startAuthentication({
optionsJSON: {
challenge,
rpId: 'your-domain.com',
allowCredentials: [{ id: credentialId, type: 'public-key' }],
userVerification: 'required',
},
});
await fetch('/payments/approve', {
method: 'POST',
body: JSON.stringify({ userOpHash, challenge, assertion }),
});On your server, submit the signature:
await fetch(`https://api.pilot.shiga.io/v1/userops/${userOpHash}/signatures`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SHIGA_API_KEY}`,
'x-customer-id': customerId,
'Content-Type': 'application/json',
},
body: JSON.stringify({
passkeyPublicKey: { x: passkey.x, y: passkey.y },
passkeyData: {
credential: assertion,
expectedChallenge: challenge,
expectedOrigins: ['https://your-domain.com'],
expectedRPID: 'your-domain.com',
authenticator: {
credentialId: passkey.credentialId,
credentialPublicKey: passkey.credentialPublicKey,
counter: 0,
},
},
}),
});passkeyPublicKey is the { x, y } returned when the wallets were provisioned. expectedChallenge must be the base64url encoding of the userOpHash bytes, not the hash as text.
Bitcoin, Spark, Solana, Tron and TON
On these networks the wallet key is held in our signing enclave. The enclave signs only after it verifies the customer's passkey approval of the exact transfer. See How wallets are secured.
- Choose a
transactionId. Your own integer sequence number for this transfer. Keep it the same if you retry. - Build the challenge from the wallet's
entityKey, itsmoduleIdand the transfer details (below). - Have the customer approve with their passkey, using the challenge.
- Submit with Submit transaction. We sign and broadcast in one step.
Transfer details (txParams)
| Field | Value |
|---|---|
chain | The network, for example tron. |
amount | In the asset's display unit, for example "12.5" USD₮ or "0.0001" BTC. |
recipientAddress | The destination, in the network's own address format. |
transactionId | Your integer sequence number from step 1. |
networkConfig | {}, or { "tokenMint": "..." } on Solana and { "tokenRoot": "..." } on TON to choose the token. |
selectedPayment | "native". On Solana the platform sets it. It is not part of the challenge. |
Building the challenge
The challenge is the base64url SHA-256 of a canonical JSON object: keys sorted at every level, no spaces.
| Key | Value |
|---|---|
entityKey | The wallet's entityKey from List wallets. |
moduleId | For example tron. |
txParams | Your txParams without selectedPayment, and with networkConfig cut to tokenMint or tokenRoot. |
import { createHash } from 'node:crypto';
function canonical(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonical);
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.keys(value)
.sort()
.map(key => [key, canonical((value as Record<string, unknown>)[key])]),
);
}
return value;
}
function transferChallenge(
entityKey: string,
moduleId: string,
txParams: Record<string, unknown>,
) {
const { selectedPayment, networkConfig, ...rest } = txParams;
const asset = Object.fromEntries(
Object.entries((networkConfig ?? {}) as Record<string, unknown>).filter(
([key]) => ['tokenMint', 'tokenRoot'].includes(key),
),
);
const intent =
networkConfig === undefined ? rest : { ...rest, networkConfig: asset };
return createHash('sha256')
.update(
JSON.stringify(canonical({ entityKey, moduleId, txParams: intent })),
)
.digest('base64url');
}Submitting
Send moduleId, txParams and passkeyData: the customer's assertion (credential), the challenge as expectedChallenge, and the passkey's authenticator details. The response returns the transaction with status submitted.
On a treasury wallet that needs more than one approval, each signer submits the same transfer with their own passkey. Until enough have approved, the response carries awaitingApprovals; approvals lapse after 15 minutes.
A payment to the wallet's own address is refused with 400 validation_failed.
Payment statuses and events
| Status | Event | Credit or settle? |
|---|---|---|
submitted | transaction.submitted | No |
pending | transaction.pending | No |
confirmed | transaction.success | Yes |
failed | transaction.failed | No |
transaction.reconciled is the signing side's report of the same confirmation as transaction.success; act on transaction.success. transaction.summary_corrected corrects a past transaction's details and does not change its status.
Only confirmed means paid; see Reliability. Read payments with List transactions, or receive each change as a notification. See Notifications.
Errors
| Code | Status | Cause |
|---|---|---|
validation_failed | 400 | Malformed body, an address that fails its network's checksum, an asset the network does not list, or a payment to the wallet's own address. |
insufficient_funds | 400 | The wallet cannot cover the amount and fees. |
wallet_not_found | 404 | No single wallet matched moduleId and x-customer-id. Send x-customer-id. |
chain_unavailable | 409 | The wallet is not yet deployed on this network; retry once it is. |
transfer_reverted | 409 | The network would reject the transfer. |
passkey_credential_invalid | 409 | The passkey assertion did not verify. |
paymaster_rejected | 502 | The network fee could not be sponsored; retry. |
upstream_unavailable | 502, 503, 504 | A network service did not answer in time; retry. |