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

ParameterRequiredDescriptionFormat
sigV2YesEd25519 signaturev2:{base64_signature}
sigV2TimestampYesWhen the URL was signedISO8601 with Z suffix
sigV2NonceYesReplay protectionUUID v4
sigV2FieldsRecommendedComma-separated list of signed fieldse.g. apiKey,wallets
sigV2ExpiryNoWhen the signed URL expiresISO8601, max 30 minutes
⚠️

If sigV2Fields is omitted, only apiKey, wallets, and networkWallets are assumed signed. If you sign any other fields (e.g. email, defaultAmount), you must list them in sigV2Fields.

Core parameters present in the URL but not listed in sigV2Fields are silently removed by the server. For example, [email protected] without email in sigV2Fields will 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:

LineValueNotes
1ONRAMPER-SIG-V2Fixed version prefix
2TimestampISO8601 with milliseconds, e.g. 2024-01-15T10:00:00.000Z
3NonceRandom UUID v4, fresh per request
4GETAlways GET for widget URLs
5/Always root path
6Canonical querySigned fields only, URL-encoded, sorted A–Z
7(empty line)No headers for widget URLs
8e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855SHA256 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 use application/x-www-form-urlencoded encoding (colons become %3A, spaces become +). Use URLSearchParams with .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

  1. Sign on the server. Anything shipped to the browser is leakable — treat client-side signing as a key compromise.
  2. Treat each signed URL as single-use. The nonce burns on first load, so caching or sharing a URL guarantees the next request fails.
  3. Generate a fresh UUID v4 nonce per request. Never reuse one.
  4. Keep expiry windows short. 15 minutes is the default; 30 minutes is the maximum.
  5. Sign every wallet parameter the user can influence: wallets, networkWallets, walletAddressTags.