Gasless Implementation
A detailed guide to implementing Gasless payments.
Use the Widget (Recommended)
The easiest way to use Gasless payments is via the @solo-pay/widget-js or @solo-pay/widget-react SDK. The widget automatically handles token Approve checks, Permit (EIP-2612) detection, EIP-712 signing, and relay submission.
See Widget Integration Guide for quick setup.
Custom Implementation Flow
If you need a custom flow instead of the widget, follow the steps below. All steps are client-side and use the REST API directly.
1. Create Payment (REST API — client-side)
↓
2. Token Approve check; optionally request one-time gas (frontend)
↓
3. Request EIP-712 Signature (frontend)
↓
4. Submit Gasless Request (REST API — client-side)
↓
5. Check Status (REST API — client-side)Step 1: Create Payment
Call POST /payments with the x-public-key header. This can be called directly from the browser.
const response = await fetch('https://gateway.dev.solonetwork.io/api/v1/payments', {
method: 'POST',
headers: {
'x-public-key': 'pk_xxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
orderId: 'order-001',
amount: 10.5,
tokenAddress: '0xE4C687167705Abf55d709395f92e254bdF5825a2',
successUrl: 'https://example.com/success',
failUrl: 'https://example.com/fail',
}),
});
const { data: payment } = await response.json();
// payment contains: paymentId, forwarderAddress, gatewayAddress, amount, deadline, isFaucetEnabled, ...Check forwarderAddress
payment.forwarderAddress must be present to use Gasless on that chain.
Step 2: Token Approve
Even for Gasless payments, the Relayer cannot transfer tokens unless the user has first completed an approve transaction granting the PaymentGateway contract permission to use the token.
If payment.isFaucetEnabled is true, a wallet without enough native token for this approval can request a one-time grant before calling approve:
const apiBase = 'https://gateway.dev.solonetwork.io/api/v1';
const publicKey = 'pk_xxxxx';
if (payment.isFaucetEnabled) {
const gasRes = await fetch(
`${apiBase}/payments/${payment.paymentId}/gas-status?walletAddress=${userAddress}`,
{ headers: { 'x-public-key': publicKey } }
);
if (!gasRes.ok) {
// FAUCET_DISABLED, NOT_FOUND, etc. — see API reference
const err = await gasRes.json();
throw new Error(err.code ?? 'GAS_STATUS_FAILED');
}
const gasStatus = await gasRes.json();
if (gasStatus.canReceiveGas) {
const grantRes = await fetch(`${apiBase}/payments/request-gas`, {
method: 'POST',
headers: {
'x-public-key': publicKey,
'Content-Type': 'application/json',
// Origin is set automatically in the browser. Server-side callers must set it
// when ALLOWED_WIDGET_ORIGIN is configured — see Authentication guide.
},
body: JSON.stringify({
paymentId: payment.paymentId,
walletAddress: userAddress,
}),
});
if (!grantRes.ok) {
// PAYMENT_ALREADY_GRANTED, ALREADY_GRANTED, GRANT_IN_PROGRESS, etc.
const err = await grantRes.json();
throw new Error(err.code ?? 'REQUEST_GAS_FAILED');
}
// Response returns after the grant tx is confirmed on-chain — safe to approve next.
}
}Each wallet can receive one grant per chain, and each payment can fund one grant. canReceiveGas is wallet-level only; handle PAYMENT_ALREADY_GRANTED from request-gas when another wallet already used this payment's grant. If faucet support is disabled or the wallet is ineligible, the payer must supply the approval gas normally. See API Reference for full error codes.
import { useWriteContract, useReadContract } from 'wagmi';
// 1. Check existing allowance
const { data: allowance } = useReadContract({
address: tokenAddress,
abi: ERC20ABI,
functionName: 'allowance',
args: [userAddress, gatewayAddress],
});
// 2. If insufficient, send Approve (using granted gas when eligible; otherwise user-funded gas)
if (allowance < BigInt(amount)) {
await writeContract({
address: tokenAddress,
abi: ERC20ABI,
functionName: 'approve',
args: [gatewayAddress, amount],
});
}Permit (Signature Approval) Supported Tokens
Modern tokens like USDC support Permit (EIP-2612), which replaces the approve transaction with a simple signature. If you use the official SoloPay Widget (@solo-pay/widget-js or @solo-pay/widget-react), it will automatically detect EIP-2612 support and skip the 1-time approve transaction, handling the Permit entirely gas-free via signature.
TIP
Once a sufficient amount is approved, all subsequent purchases (Step 3) can be completely gasless via Signature only.
Step 3: Request EIP-712 Signature
Request a signature from the user on the frontend.
import { useSignTypedData } from 'wagmi';
import { encodeFunctionData } from 'viem';
const { signTypedDataAsync } = useSignTypedData();
// Fetch current nonce from Forwarder
const nonce = await publicClient.readContract({
address: forwarderAddress,
abi: ERC2771ForwarderABI,
functionName: 'nonces',
args: [userAddress],
});
// Build Forward Request (PaymentGateway.pay — deadline from API response)
const forwardRequest = {
from: userAddress,
to: gatewayAddress,
value: 0n,
gas: 200000n,
nonce,
deadline: BigInt(Math.floor(Date.now() / 1000) + 3600), // 1 hour
data: encodeFunctionData({
abi: PaymentGatewayABI,
functionName: 'pay',
args: [
paymentId,
tokenAddress,
BigInt(amount),
recipientAddress,
merchantId,
BigInt(deadline), // from payment.deadline (API)
permitData, // EIP-2612 permit, or zero permit { deadline: 0, v: 0, r: '0x00...', s: '0x00...' }
],
}),
};
// EIP-712 Sign — domain name/version must match the forwarder contract used by the relay API (e.g. SoloPay, SoloForwarder, or ERC2771Forwarder)
const signature = await signTypedDataAsync({
domain: {
name: 'ERC2771Forwarder', // Must match your deployed forwarder; relay server validates this
version: '1',
chainId: 80002, // Polygon Amoy
verifyingContract: forwarderAddress,
},
types: {
ForwardRequest: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'gas', type: 'uint256' },
{ name: 'nonce', type: 'uint256' },
{ name: 'deadline', type: 'uint48' },
{ name: 'data', type: 'bytes' },
],
},
primaryType: 'ForwardRequest',
message: forwardRequest,
});Important
Signing is NOT a transaction, so no gas fees are charged. The pay function requires deadline from the API response; use a zero permit when not using EIP-2612.
Step 4: Submit Gasless Request
Endpoint: POST /payments/:id/relay
const result = await fetch(
`https://gateway.dev.solonetwork.io/api/v1/payments/${payment.paymentId}/relay`,
{
method: 'POST',
headers: {
'x-public-key': 'pk_xxxxx',
'Content-Type': 'application/json',
},
body: JSON.stringify({
paymentId: payment.paymentId,
forwarderAddress: payment.forwarderAddress,
forwardRequest: {
from: forwardRequest.from,
to: forwardRequest.to,
value: '0',
gas: '200000',
nonce: forwardRequest.nonce.toString(),
deadline: forwardRequest.deadline.toString(),
data: forwardRequest.data,
signature,
},
}),
}
).then((r) => r.json());Step 5: Check Status
// Relay status (by paymentId)
const relayStatus = await fetch(
`https://gateway.dev.solonetwork.io/api/v1/payments/${paymentId}/relay`,
{ headers: { 'x-public-key': 'pk_xxxxx' } }
).then((r) => r.json());
// relayStatus.data.status: 'QUEUED' | 'SUBMITTED' | 'CONFIRMED' | 'FAILED'
// Payment status
const paymentStatus = await fetch(
`https://gateway.dev.solonetwork.io/api/v1/payments/${paymentId}`,
{
headers: { 'x-public-key': 'pk_xxxxx' },
}
).then((r) => r.json());
// paymentStatus.data.status: 'CREATED' | 'PAID' | 'REFUND_SUBMITTED' | 'REFUNDED' | 'INVALID' | 'EXPIRED' | 'FAILED'Full Example (React + wagmi)
function GaslessPayment({ payment }) {
const { address } = useAccount();
const publicClient = usePublicClient();
const { signTypedDataAsync } = useSignTypedData();
const { paymentId, forwarderAddress, gatewayAddress, amount, tokenAddress,
recipientAddress, merchantId, deadline, chainId } = payment;
const handleGaslessPayment = async () => {
const nonce = await publicClient.readContract({
address: forwarderAddress, abi: ERC2771ForwarderABI,
functionName: 'nonces', args: [address],
});
const payDeadline = BigInt(deadline);
const zeroPermit = { deadline: 0, v: 0, r: '0x0000000000000000000000000000000000000000000000000000000000000000' as const, s: '0x0000000000000000000000000000000000000000000000000000000000000000' as const };
const forwardRequest = {
from: address, to: gatewayAddress, value: 0n, gas: 200000n, nonce,
deadline: BigInt(Math.floor(Date.now() / 1000) + 3600),
data: encodeFunctionData({
abi: PaymentGatewayABI, functionName: 'pay',
args: [paymentId, tokenAddress, BigInt(amount), recipientAddress, merchantId, payDeadline, zeroPermit],
}),
};
const signature = await signTypedDataAsync({
domain: { name: 'ERC2771Forwarder', version: '1', chainId, verifyingContract: forwarderAddress },
types: {
ForwardRequest: [
{ name: 'from', type: 'address' }, { name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' }, { name: 'gas', type: 'uint256' },
{ name: 'nonce', type: 'uint256' }, { name: 'deadline', type: 'uint48' },
{ name: 'data', type: 'bytes' },
],
},
primaryType: 'ForwardRequest', message: forwardRequest,
});
const result = await fetch(
`https://gateway.dev.solonetwork.io/api/v1/payments/${paymentId}/relay`,
{
method: 'POST',
headers: { 'x-public-key': 'pk_xxxxx', 'Content-Type': 'application/json' },
body: JSON.stringify({
paymentId, forwarderAddress,
forwardRequest: {
from: forwardRequest.from, to: forwardRequest.to,
value: '0', gas: '200000',
nonce: forwardRequest.nonce.toString(),
deadline: forwardRequest.deadline.toString(),
data: forwardRequest.data, signature,
},
}),
}
).then((r) => r.json());
return result;
};
return <button onClick={handleGaslessPayment}>Pay without gas</button>;
}Error Handling
| Error Code | Cause | Resolution |
|---|---|---|
INVALID_SIGNATURE | Invalid signature format | Ensure signature is a hex string starting with 0x |
INVALID_PAYMENT_STATUS | Payment in terminal state (e.g. PAID, REFUNDED, EXPIRED etc.) | Only send relay when status is CREATED; prevent duplicate requests |
PAYMENT_EXPIRED | Payment expired | Create a new payment and retry |
RELAYER_NOT_CONFIGURED | No Relayer for this chain | Verify supported chains |
VALIDATION_ERROR | Input validation failed | Verify forwardRequest amount matches payment amount |
Next Steps
- Webhooks - Receive payment completion notifications
- Error Codes - Full error list