Widget V2 Integration

Widget v2 removes all sensitive information from the browser URL. Instead, your backend securely creates a session through a signed server-to-server API call, and the customer receives a single opaque link (?sessionId=...). API keys, order details, signatures, and other sensitive information never reach the browser.

✨ Key security improvements

  • 🔒 API key protectionapiKey is sent as a header on your backend call, never exposed in the browser.
  • 🧾 Request integrity — every session-creation call is authenticated with an HMAC‑SHA256 signature.
  • ⏱️ Session-based — secure, single-use sessions prevent replay attacks and session reuse.
  • 🛡️ IP allow-listing (required today) — session creation requests are accepted only from your pre-registered backend egress IPs. You can manage and update your IP whitelist directly from the dashboard.
  • 🎯 v2-only org mode (optional, on request) — once your integration is migrated to Widget v2, we can disable your legacy widget access, ensuring only session-based Widget v2 integrations are accepted across your organization.

Widget v1 vs. Widget v2

FeatureWidget v1 (legacy)Widget v2 (session)
How you launchRedirect to https://buy.transfi.com?apiKey=...&view=buy&...&signature=...Backend calls POST /v2/ramp-session → redirect to https://buy.transfi.com?sessionId=RS-...
API key exposure❌ Exposed in URL✅ Header only, server-side
Request integritysignature query param✅ HMAC‑SHA256 over the request
Data in browser URLFull order + customer details, in the clearOpaque sessionId only
IP whitelisting❌ Not available✅ Required — could be done from the dashboard
Integration steps1 step (client-side redirect)2 steps (backend session + client redirect)
Still supported?✅ Yes, but will be deprecated✅ Recommended for all integrations

Integration overview

Widget v2 uses a two-step integration process:

1️⃣ Create Session (Server-Side)
Call the Ramp Session API with your order/customer parameters and a signature — get back a sessionId and redirectUrl.

2️⃣ Redirect Customer (Client-Side)
Send the customer to redirectUrl. The widget resolves the session on load and prefills itself.


Step 1: Create a Session (Server-Side)

Call this from your backend only — never from client-side code, since it requires your HMAC secret.

API Endpoint

MethodPOST
Path/v2/ramp-session
Sandboxhttps://sandbox-ramp-server.transfi.com/v2/ramp-session
Productionhttps://ramp-server.transfi.com/v2/ramp-session

Headers

HeaderRequiredDescription
Content-TypeYesapplication/json
x-api-keyYesYour org apiKey
x-timestampYesCurrent time in epoch milliseconds. Must be within ±5 minutes of server time.
x-signatureYesLowercase-hex HMAC‑SHA256 signature — see signing scheme below.

Signing scheme

message   = TIMESTAMP + "." + BODY        // BODY = exact raw JSON string sent on the wire
signature = HMAC_SHA256(message, SECRET)  // lowercase hex
⚠️

Sign the exact bytes you send. The server verifies against the raw request body it received on the wire. If your HTTP client re-serializes the JSON after you compute the signature (reordering keys, changing whitespace), the signature will not match — compute it over the same string that goes on the wire, and do not JSON.parse + re-stringify before signing.

Your HMAC secret is per-organization and per-environment (a separate secret for sandbox vs. production) — this could be done from the dashboard.

🛡️

IP allow-listing is required today. POST /v2/ramp-session rejects requests from any IP that isn't on your org's whitelist with 403 IP Not Configured — even before the HMAC signature is checked. This could be done from the dashboard before you start integrating.

Request Parameters

Only the fields you send are stored; anything omitted just won't be prefilled. view is required so the widget knows which flow to render.

ParameterTypeRequiredDescriptionExample
viewstringOptional"buy" or "sell"."buy"
emailstringOptionalCustomer email. If provided, skips the registration step."[email protected]"
firstName / lastNamestringOptionalCustomer name."Jane" / "Doe"
phone / phoneCodestringOptionalCustomer phone and dialing code. (Accepted at creation — see Things to know for a current prefill limitation.)"9876543210" / "+1"
countrystringOptionalISO 3166-1 alpha-2 country code."US"
fiatAmountnumberOptionalPre-filled fiat amount.100
fiatTickerstringOptionalFiat currency code."USD"
cryptoTickerstringOptionalCrypto asset symbol."USDT"
cryptoAmountstringOptionalPre-filled crypto amount (alternative to fiatAmount).""
cryptoNetworkstringOptionalChain/network for the asset."tron"
walletAddressstringOptionalDestination wallet address. If set, the customer cannot edit it."0xAbC123..."
paymentCodestringOptionalPre-select a payment method."credit_card"
quoteIdstringOptionalReference to a previously fetched quote.""
sumsubKycToken / kycTokenstringOptionalPre-existing KYC token to skip re-verification.
redirectUrlstringOptionalWhere to send the customer after the order completes."https://merchant.com/return"
webhookUrlstringOptionalOrder-status webhook for this session."https://merchant.com/webhook"
merchantUrlstringOptionalYour site URL, surfaced back to the widget/session context."https://merchant.com"
partnerContextstringOptionalFree-form context echoed back to you."order-789"
customerOrderIdstringOptionalYour own order reference. (Accepted at creation — see Things to know for a current prefill limitation.)"ORD-789"

webhookUrl, redirectUrl, and merchantUrl must be well-formed, externally-routable http(s) URLs — the server validates them at session creation and rejects unsafe values with 400.

Example Request

const crypto = require("crypto");
const axios = require("axios");

const API_KEY = "YOUR_API_KEY";
const SECRET = "YOUR_ORG_HMAC_SECRET"; // generate from the dashboard
const HOST = "https://sandbox-ramp-server.transfi.com";

const payload = {
  view: "buy",
  email: "[email protected]",
  firstName: "John",
  lastName: "Doe",
  phone: "1234567890",
  phoneCode: "+1",
  country: "IN",
  fiatAmount: "100",
  fiatTicker: "USD",
  cryptoTicker: "USDT",
  cryptoNetwork: "tron",
  redirectUrl: "",
  webhookUrl: "https://example.com/webhook",
  partnerContext: "ctx-123",
  customerOrderId: "order-12345",
};

// Sign the EXACT bytes you send on the wire.
const body = JSON.stringify(payload);
const timestamp = Date.now().toString();
const message = `${timestamp}.${body}`;
const signature = crypto
  .createHmac("sha256", SECRET)
  .update(message)
  .digest("hex");

const { data } = await axios.post(`${HOST}/v2/ramp-session`, body, {
  headers: {
    "Content-Type": "application/json",
    "x-api-key": API_KEY,
    "x-timestamp": timestamp,
    "x-signature": signature,
  },
});

// Redirect the customer to this URL.
console.log(data.data.redirectUrl); // https://sandbox-buy.transfi.com?sessionId=RS-...
import hashlib
import hmac
import json
import time

import requests

API_KEY = "YOUR_API_KEY"
SECRET = "YOUR_ORG_HMAC_SECRET"  # generate from the dashboard
HOST = "https://sandbox-ramp-server.transfi.com"

payload = {
    "view": "buy",
    "email": "[email protected]",
    "firstName": "John",
    "lastName": "Doe",
    "phone": "1234567890",
    "phoneCode": "+1",
    "country": "IN",
    "fiatAmount": "100",
    "fiatTicker": "USD",
    "cryptoTicker": "USDT",
    "cryptoNetwork": "tron",
    "redirectUrl": "",
    "webhookUrl": "https://example.com/webhook",
    "partnerContext": "ctx-123",
    "customerOrderId": "order-12345",
}

# Sign the EXACT bytes you send on the wire.
body = json.dumps(payload)
timestamp = str(int(time.time() * 1000))
message = f"{timestamp}.{body}"
signature = hmac.new(SECRET.encode(), message.encode(), hashlib.sha256).hexdigest()

response = requests.post(
    f"{HOST}/v2/ramp-session",
    data=body,
    headers={
        "Content-Type": "application/json",
        "x-api-key": API_KEY,
        "x-timestamp": timestamp,
        "x-signature": signature,
    },
)

# Redirect the customer to this URL.
redirect_url = response.json()["data"]["redirectUrl"]
print(redirect_url)  # https://sandbox-buy.transfi.com?sessionId=RS-...
{
  "info": {
    "name": "TransFi Ramp Session API",
    "description": "Create a Ramp Session (POST /v2/ramp-session) with automatic HMAC-SHA256 signature generation.\n\n**Quick Start:**\n1. Import this collection into Postman\n2. Click on 'TransFi Ramp Session API' collection → Variables tab\n3. Add your PUBLIC_KEY (x-api-key) and SECRET_KEY in the 'Current Value' column\n4. Send the request!\n\n**Signing scheme:**\n  message   = TIMESTAMP + \".\" + BODY\n  signature = HMAC_SHA256(message, SECRET_KEY) -> lowercase hex\n\nx-timestamp is bound INTO the signed message (so a captured signature can't be replayed under a new timestamp) AND checked for freshness (within 5 minutes of server time). The signature and timestamp are generated automatically by the pre-request script.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "variable": [
    {
      "key": "PUBLIC_KEY",
      "value": "",
      "type": "string"
    },
    {
      "key": "SECRET_KEY",
      "value": "",
      "type": "string"
    },
    {
      "key": "API_BASE_URL",
      "value": "https://sandbox-ramp-server.transfi.com",
      "type": "string"
    },
    {
      "key": "timestamp",
      "value": "",
      "type": "string"
    },
    {
      "key": "signature",
      "value": "",
      "type": "string"
    }
  ],
  "item": [
    {
      "name": "Create Ramp Session",
      "event": [
        {
          "listen": "prerequest",
          "script": {
            "exec": [
              "// Check if API keys are configured",
              "const publicKey = pm.collectionVariables.get(\"PUBLIC_KEY\");",
              "const secretKey = pm.collectionVariables.get(\"SECRET_KEY\");",
              "",
              "if (!publicKey || !secretKey || publicKey === \"\" || secretKey === \"\") {",
              "    console.error(\"❌ API Keys not configured!\");",
              "    console.log(\"📝 Please configure your API keys:\");",
              "    console.log(\"   1. Click on 'TransFi Ramp Session API' collection name\");",
              "    console.log(\"   2. Go to 'Variables' tab\");",
              "    console.log(\"   3. Add your PUBLIC_KEY and SECRET_KEY in 'Current Value' column\");",
              "    console.log(\"   4. Click 'Save' and try again\");",
              "    throw new Error(\"API keys not configured. Please add PUBLIC_KEY and SECRET_KEY in collection variables.\");",
              "}",
              "",
              "// Generate timestamp (ms epoch) — sent as x-timestamp AND bound into the signature.",
              "const timestamp = Date.now().toString();",
              "",
              "// Sign the EXACT raw body bytes that go on the wire. The server verifies over",
              "// req.rawBody (not a re-serialized copy), so do NOT JSON.parse + re-stringify here —",
              "// that would change whitespace/key order and cause a signature mismatch. The body",
              "// below contains no {{variables}}, so pm.request.body.raw equals what is sent.",
              "const requestBodyString = pm.request.body.raw;",
              "",
              "// Ramp-session signing message = TIMESTAMP + \".\" + BODY (raw bytes).",
              "const signingMessage = timestamp + \".\" + requestBodyString;",
              "",
              "// Generate HMAC SHA256 signature (lowercase hex).",
              "const signature = CryptoJS.HmacSHA256(signingMessage, secretKey).toString(CryptoJS.enc.Hex);",
              "",
              "// Set variables for use in request headers.",
              "pm.collectionVariables.set(\"timestamp\", timestamp);",
              "pm.collectionVariables.set(\"signature\", signature);",
              "",
              "console.log(\"✅ Signature generated successfully\");",
              "console.log(\"📅 Timestamp:\", timestamp);",
              "console.log(\"📦 Signed body:\", requestBodyString);",
              "console.log(\"🔐 Signature:\", signature);"
            ],
            "type": "text/javascript"
          }
        },
        {
          "listen": "test",
          "script": {
            "exec": [
              "// Parse response",
              "const response = pm.response.json();",
              "",
              "// Log response",
              "console.log(\"📬 Response:\", response);",
              "",
              "// Check if request was successful",
              "if (pm.response.code === 200 && response.success) {",
              "    console.log(\"✅ Ramp session created successfully!\");",
              "    console.log(\"🆔 Session ID:\", response.data.sessionId);",
              "    console.log(\"🔗 Widget URL:\", response.data.redirectUrl);",
              "    // Save for chaining into a follow-up resolve request if needed.",
              "    pm.collectionVariables.set(\"sessionId\", response.data.sessionId);",
              "} else {",
              "    console.log(\"❌ Request failed:\", response);",
              "}"
            ],
            "type": "text/javascript"
          }
        }
      ],
      "request": {
        "method": "POST",
        "header": [
          {
            "key": "Content-Type",
            "value": "application/json",
            "type": "text"
          },
          {
            "key": "x-api-key",
            "value": "{{PUBLIC_KEY}}",
            "type": "text"
          },
          {
            "key": "x-timestamp",
            "value": "{{timestamp}}",
            "type": "text"
          },
          {
            "key": "x-signature",
            "value": "{{signature}}",
            "type": "text"
          }
        ],
        "body": {
          "mode": "raw",
          "raw": "{\n  \"view\": \"buy\",\n  \"email\": \"[email protected]\",\n  \"firstName\": \"John\",\n  \"lastName\": \"Doe\",\n  \"phone\": \"1234567890\",\n  \"phoneCode\": \"+1\",\n  \"country\": \"IN\",\n  \"fiatAmount\": \"100\",\n  \"fiatTicker\": \"USD\",\n  \"cryptoTicker\": \"USDT\",\n  \"cryptoAmount\": \"\",\n  \"cryptoNetwork\": \"tron\",\n  \"walletAddress\": \"\",\n  \"paymentCode\": \"\",\n  \"quoteId\": \"\",\n  \"redirectUrl\": \"\",\n  \"webhookUrl\": \"https://example.com/webhook\",\n  \"partnerContext\": \"ctx-123\",\n  \"customerOrderId\": \"order-12345\"\n}",
          "options": {
            "raw": {
              "language": "json"
            }
          }
        },
        "url": {
          "raw": "{{API_BASE_URL}}/v2/ramp-session",
          "host": [
            "{{API_BASE_URL}}"
          ],
          "path": [
            "v2",
            "ramp-session"
          ]
        },
        "description": "Creates a merchant-initiated ramp session. Returns { sessionId, redirectUrl }. Open redirectUrl (…?sessionId=RS_...) in the widget to launch the prefilled flow.\n\n**The signature is automatically generated** by the pre-request script — you don't need to do anything.\n\nOnly these top-level fields are stored (SESSION_DETAIL_FIELDS): view, email, firstName, lastName, phone, phoneCode, country, fiatAmount, fiatTicker, cryptoTicker, cryptoAmount, cryptoNetwork, walletAddress, paymentCode, quoteId, sumsubKycToken, kycToken, merchantUrl, redirectUrl, webhookUrl, partnerContext, customerOrderId. Anything else is dropped. `view` is required so the widget knows which flow (buy/sell) to render."
      },
      "response": [
        {
          "name": "Successful Response",
          "originalRequest": {
            "method": "POST",
            "header": [
              { "key": "Content-Type", "value": "application/json" },
              { "key": "x-api-key", "value": "{{PUBLIC_KEY}}" },
              { "key": "x-timestamp", "value": "{{timestamp}}" },
              { "key": "x-signature", "value": "{{signature}}" }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"view\": \"buy\",\n  \"email\": \"[email protected]\",\n  \"fiatAmount\": \"100\",\n  \"fiatTicker\": \"USD\",\n  \"cryptoTicker\": \"USDT\",\n  \"cryptoNetwork\": \"tron\"\n}"
            },
            "url": {
              "raw": "{{API_BASE_URL}}/v2/ramp-session",
              "host": ["{{API_BASE_URL}}"],
              "path": ["v2", "ramp-session"]
            }
          },
          "status": "OK",
          "code": 200,
          "_postman_previewlanguage": "json",
          "header": [{ "key": "Content-Type", "value": "application/json" }],
          "body": "{\"success\": true,\"data\": {\"sessionId\": \"RS_123456\",\"redirectUrl\": \"https://sandbox-buy.transfi.com?sessionId=RS_123456\"}}"
        }
      ]
    }
  ]
}

Using the Postman collection:

  1. Copy the JSON from the Postman Collection tab and save it as a .json file (or paste it directly via Postman's Import → Raw text).
  2. Open the collection → Variables tab → set PUBLIC_KEY (your x-api-key) and SECRET_KEY (your HMAC secret) in the Current Value column → Save.
  3. Open Create Ramp SessionSend. The pre-request script computes x-timestamp and x-signature for you automatically — nothing else to configure.

Response

 {
    "success": true,
    "data": {
      "sessionId": "RS-550e8400-e29b-41d4-a716-446655440000",
      "redirectUrl": "https://sandbox-buy.transfi.com?sessionId=RS-550e8400-e29b-41d4-a716-446655440000"
    }
 }
ℹ️

Session lifecycle. A session can be resolved by the widget exactly once (it flips from createdinprogress), and it stays resolvable for 1 hour after creation. Once an order is placed against it, it's marked consumed. Generate a fresh session per customer launch, right before you redirect them.


Step 2: Redirect the Customer (Client-Side)

Redirect the customer's browser to the redirectUrl from the response above. That opaque, single-use URL is the only thing the browser ever sees — the widget resolves the session on load and prefills itself from the details you supplied in Step 1.

https://sandbox-buy.transfi.com?sessionId=RS-550e8400-e29b-41d4-a716-446655440000

The widget resolves the session via an internal call (POST /v2/ramp-session/resolve) as soon as it loads — this happens automatically; you don't need to call it yourself.


Migrating from Widget v1 (legacy)

Legacy URL query paramNew /v2/ramp-session fieldNotes
apiKeyx-api-key headerNo longer in the URL — used to look up your org and HMAC secret.
signaturex-signature headerWas HMAC over the query string; now HMAC‑SHA256 over timestamp + "." + body.
view (or product)viewproduct was the legacy alias.
email, firstName, lastNamesame
countrycountryISO 3166-1 alpha-2.
fiatTicker, fiatAmountsame
cryptoTicker, cryptoNetworksame
cryptoAmount, walletAddress, paymentCodesame
kycToken / sumsubKycTokensame
quoteId, redirectUrl, partnerContext, merchantUrlsame
webhookUrlNew — optional order webhook.
phone / phoneCodeNew — accepted at session creation, but not yet returned when the widget resolves the session, so it currently won't prefill. Known limitation.
customerOrderIdNew — accepted at session creation, but likewise not yet returned on resolve. Known limitation.

The signature query param is no longer verified. The widget stopped checking it client-side; existing URLs that still include it will continue to load — the parameter is simply ignored. Request integrity now comes from the HMAC-signed session call instead.


Things to know

  • Sessions can be resolved by the widget only once. A second resolve attempt on the same sessionId is rejected. Generate a fresh session per customer launch.
  • Sessions stay resolvable for 1 hour after creation, then expire. Create the session shortly before you redirect the customer.
  • IP allow-listing is mandatory today, not optional — POST /v2/ramp-session returns 403 IP Not Configured for any org without a whitelist configured, regardless of signature validity. This could be done from the dashboard before going live.
  • Timestamp window is ±5 minutes. Make sure your server clock is accurate (NTP-synced).
  • Sandbox and production use different HMAC secrets and hosts. This could be done from the dashboard; test against sandbox first.
  • phone, phoneCode, and customerOrderId are accepted at creation but not yet prefilled by the widget when it resolves the session — this is a known gap being worked on.
  • v2-only org mode is opt-in — contact TransFi if you want raw apiKey-in-URL requests rejected outright for your org.
  • The legacy query-string launch still works today for existing orgs, but will be deprecated soon — we strongly recommend migrating now.

Troubleshooting

ErrorStatusLikely causeFix
IP Not Configured403Your org has no whitelisted egress IPs on file, or the calling server's IP isn't in the list.This could be done from the dashboard.
IP address not whitelisted403Your org has a whitelist, but this request came from an IP not on it.Confirm the outbound IP your server actually uses (check for NAT/proxy); this could be done from the dashboard.
Signature secret not configured for this org403No HMAC secret has been generated for your org in this environment yet.This could be done from the dashboard.
Missing x-api-key, x-timestamp, or x-signature header401One or more required headers weren't sent.Verify all three headers are present on the request.
Invalid timestamp format401x-timestamp isn't a valid epoch-milliseconds integer string.Send Date.now() (JS) / int(time.time() * 1000) (Python) as a string.
Invalid timestamp - possible replay attack401x-timestamp is more than 5 minutes off from server time.Sync your server clock (NTP); regenerate the timestamp right before sending.
Signature mismatch (generic 401)401The signed message doesn't match what the server received — usually because the body was re-serialized after signing.Sign the exact raw JSON string you send on the wire; don't JSON.parse/re-stringify between signing and sending.
Invalid <field>: ...400webhookUrl, redirectUrl, or merchantUrl failed URL validation.Ensure the URL is a well-formed, externally-routable http(s) URL.
Invalid or expired session401The sessionId doesn't exist, has already been resolved once, or is past its 1-hour TTL.Generate a new session per customer launch; don't reuse or cache redirectUrls.
An order has already been created for this sessionThe session was already consumed by a completed order (one order per session).Create a fresh session if the customer needs to place another order.

Need help?

Your HMAC secret and IP allow-listing could be done from the dashboard. For anything else, including enabling optional controls like v2-only org mode, reach out to your TransFi contact.


Did this page help you?