Widget: Sign a URL (V2)
When your widget URL includes wallet addresses or other sensitive parameters, you must sign the URL server-side with V2 and append the signature parameters. If the signature is invalid, missing, or expired, the transaction is restricted at checkout.
V2 URL parameters
| Parameter | Required | Description | Format |
|---|---|---|---|
sigV2 | Yes | Ed25519 signature | v2:{base64_signature} |
sigV2Timestamp | Yes | When the URL was signed | ISO8601 with Z suffix |
sigV2Nonce | Yes | Replay protection | UUID v4 |
sigV2Fields | Recommended | Comma-separated list of signed fields | e.g. apiKey,wallets |
sigV2Expiry | No | When the signed URL expires | ISO8601, max 30 minutes |
IfsigV2Fieldsis omitted, onlyapiKey,wallets, andnetworkWalletsare assumed signed. If you sign any other fields (e.g.defaultAmount), you must list them insigV2Fields.
Core parameters present in the URL but not listed insigV2Fieldsare silently removed by the server. For example,[email protected]withoutsigV2Fieldswill be stripped and ignored.
Which parameters must be signed
All core parameters must be signed when present in your URL. These include apiKey (always required), wallets, networkWallets, walletAddressTags, defaultAmount, defaultFiat, defaultCrypto, defaultPaymentMethod, onlyOnramps, mode, successRedirectUrl, failureRedirectUrl, partnerContext, redirectAtCheckout, redirectTo, uniqueUserId, email, kycProvider, kycToken, onlyCryptos, onlyCryptoNetworks, onlyFiats, excludeCryptos, excludeCryptoNetworks, excludeFiats, excludePaymentMethods, isAddressEditable, isAmountEditable, and enableAuth.
Theme and display parameters (themeName, primaryColor, borderRadius, txnAmount, etc.) do not need to be signed.
How to generate the signature
1. Build the canonical string
V2 signs an 8-line string joined by newline characters (\n). For widget URLs:
| Line | Value | Notes |
|---|---|---|
| 1 | ONRAMPER-SIG-V2 | Fixed version prefix |
| 2 | Timestamp | ISO8601 with milliseconds, e.g. 2024-01-15T10:00:00.000Z |
| 3 | Nonce | Random UUID v4, fresh per request |
| 4 | GET | Always GET for widget URLs |
| 5 | / | Always root path |
| 6 | Canonical query | Signed fields only, URL-encoded, sorted A–Z |
| 7 | (empty line) | No headers for widget URLs |
| 8 | e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 | SHA256 of empty string (no body) |
Example canonical string for apiKey=pk_test_xxx, wallets=btc:bc1qxy..., defaultAmount=100:
ONRAMPER-SIG-V2
2024-01-15T10:00:00.000Z
550e8400-e29b-41d4-a716-446655440000
GET
/
apiKey=pk_test_xxx&defaultAmount=100&wallets=btc%3Abc1qxy...
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
The canonical query must useapplication/x-www-form-urlencodedencoding (colons become%3A, spaces become+). UseURLSearchParamswith.sort()in JavaScript — it matches our server-side verification exactly. Note this differs from V1, which signed unencoded values.
2. Sign with Ed25519 and prefix with v2:
Sign the canonical string with your private key, base64-encode the result, and prefix it with exactly v2: (three characters).
3. Append signature parameters to the URL
Complete TypeScript example
import { createHash, createPrivateKey, randomUUID, sign } from 'crypto';
function sha256(data: string): string {
return createHash('sha256').update(data).digest('hex');
}
interface SignWidgetUrlOptions {
baseUrl: string;
privateKeyPem: string;
fields: Record<string, string>; // All signed fields — apiKey MUST be included
expiryMinutes?: number;
}
function signWidgetUrlV2(options: SignWidgetUrlOptions): string {
const { baseUrl, privateKeyPem, fields, expiryMinutes = 15 } = options;
const timestamp = new Date().toISOString();
const nonce = randomUUID();
const expiry = new Date(Date.now() + expiryMinutes * 60 * 1000).toISOString();
// Canonical query: URLSearchParams matches server-side encoding exactly
const canonicalParams = new URLSearchParams(fields);
canonicalParams.sort();
const canonicalQuery = canonicalParams.toString();
// 8-line canonical string
const contentToSign = [
'ONRAMPER-SIG-V2',
timestamp,
nonce,
'GET',
'/',
canonicalQuery,
'', // No headers for widget URLs
sha256(''), // Empty body hash
].join('\n');
const privateKey = createPrivateKey(privateKeyPem);
const signature = sign(null, Buffer.from(contentToSign), privateKey);
const url = new URL(baseUrl);
for (const [key, value] of Object.entries(fields)) {
url.searchParams.set(key, value);
}
url.searchParams.set('sigV2', `v2:${signature.toString('base64')}`);
url.searchParams.set('sigV2Timestamp', timestamp);
url.searchParams.set('sigV2Nonce', nonce);
url.searchParams.set('sigV2Expiry', expiry);
url.searchParams.set('sigV2Fields', Object.keys(fields).sort().join(','));
return url.toString();
}
// Usage — apiKey MUST be included in fields
const widgetUrl = signWidgetUrlV2({
baseUrl: 'https://buy.onramper.com',
privateKeyPem: process.env.PRIVATE_KEY!,
fields: {
apiKey: 'YOUR_API_KEY',
wallets: 'btc:bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh',
defaultAmount: '100',
defaultFiat: 'USD',
},
});The final URL
https://buy.onramper.com
?apiKey=YOUR_API_KEY
&wallets=btc:bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh
&defaultAmount=100
&sigV2=v2:MEUCIQDKZf3L8gT2x...
&sigV2Timestamp=2024-01-15T10:00:00.000Z
&sigV2Nonce=550e8400-e29b-41d4-a716-446655440000
&sigV2Expiry=2024-01-15T10:15:00.000Z
&sigV2Fields=apiKey,defaultAmount,wallets
Best practices
- Sign on the server. Anything shipped to the browser is leakable — treat client-side signing as a key compromise.
- Treat each signed URL as single-use. The nonce burns on first load, so caching or sharing a URL guarantees the next request fails.
- Generate a fresh UUID v4 nonce per request. Never reuse one.
- Keep expiry windows short. 15 minutes is the default; 30 minutes is the maximum.
- Sign every wallet parameter the user can influence:
wallets,networkWallets,walletAddressTags.

