---
updatedAt: 2026-07-27T07:07:13.000Z
agentTools:
  projectIndex: https://ramp-docs.transfi.com/llms.txt
---

# 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 protection** — `apiKey` 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

| Feature             | Widget v1 (legacy)                                                          | Widget v2 (session)                                                                            |
| ------------------- | --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| How you launch      | Redirect 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 integrity   | `signature` query param                                                     | ✅ HMAC‑SHA256 over the request                                                                 |
| Data in browser URL | Full order + customer details, in the clear                                 | Opaque `sessionId` only                                                                        |
| IP whitelisting     | ❌ Not available                                                             | ✅ Required — could be done from the dashboard                                                  |
| Integration steps   | 1 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

|                |                                                           |
| -------------- | --------------------------------------------------------- |
| **Method**     | `POST`                                                    |
| **Path**       | `/v2/ramp-session`                                        |
| **Sandbox**    | `https://sandbox-ramp-server.transfi.com/v2/ramp-session` |
| **Production** | `https://ramp-server.transfi.com/v2/ramp-session`         |

### Headers

| Header         | Required | Description                                                                           |
| -------------- | -------- | ------------------------------------------------------------------------------------- |
| `Content-Type` | Yes      | `application/json`                                                                    |
| `x-api-key`    | Yes      | Your org `apiKey`                                                                     |
| `x-timestamp`  | Yes      | Current time in **epoch milliseconds**. Must be within **±5 minutes** of server time. |
| `x-signature`  | Yes      | Lowercase-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
```

<Callout icon="⚠️" theme="warn">
  ### **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.
</Callout>

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

<Callout icon="🛡️" theme="default">
  ### **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.
</Callout>

### 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.

| Parameter                         | Type   | Required | Description                                                                                                                         | Example                          |
| --------------------------------- | ------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- |
| `view`                            | string | Optional | `"buy"` or `"sell"`.                                                                                                                | `"buy"`                          |
| `email`                           | string | Optional | Customer email. If provided, skips the registration step.                                                                           | `"jane@example.com"`             |
| `firstName` / `lastName`          | string | Optional | Customer name.                                                                                                                      | `"Jane"` / `"Doe"`               |
| `phone` / `phoneCode`             | string | Optional | Customer phone and dialing code. *(Accepted at creation — see [Things to know](#things-to-know) for a current prefill limitation.)* | `"9876543210"` / `"+1"`          |
| `country`                         | string | Optional | ISO 3166-1 alpha-2 country code.                                                                                                    | `"US"`                           |
| `fiatAmount`                      | number | Optional | Pre-filled fiat amount.                                                                                                             | `100`                            |
| `fiatTicker`                      | string | Optional | Fiat currency code.                                                                                                                 | `"USD"`                          |
| `cryptoTicker`                    | string | Optional | Crypto asset symbol.                                                                                                                | `"USDT"`                         |
| `cryptoAmount`                    | string | Optional | Pre-filled crypto amount (alternative to `fiatAmount`).                                                                             | `""`                             |
| `cryptoNetwork`                   | string | Optional | Chain/network for the asset.                                                                                                        | `"tron"`                         |
| `walletAddress`                   | string | Optional | Destination wallet address. If set, the customer cannot edit it.                                                                    | `"0xAbC123..."`                  |
| `paymentCode`                     | string | Optional | Pre-select a payment method.                                                                                                        | `"credit_card"`                  |
| `quoteId`                         | string | Optional | Reference to a previously fetched quote.                                                                                            | `""`                             |
| `kycToken/`<br />`sumsubKycToken` | string | Optional | Pre-existing KYC token to skip re-verification.                                                                                     | —                                |
| `redirectUrl`                     | string | Optional | Where to send the customer after the order completes.                                                                               | `"https://merchant.com/return"`  |
| `webhookUrl`                      | string | Optional | Order-status webhook for this session.                                                                                              | `"https://merchant.com/webhook"` |
| `merchantUrl`                     | string | Optional | Your site URL, surfaced back to the widget/session context.                                                                         | `"https://merchant.com"`         |
| `partnerContext`                  | string | Optional | Free-form context echoed back to you.                                                                                               | `"order-789"`                    |
| `customerOrderId`                 | string | Optional | Your own order reference. *(Accepted at creation — see [Things to know](#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

```javascript Node.js
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: "test1234@transfi.com",
  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-...
```
```python Python
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": "test1234@transfi.com",
    "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-...
```
```json Postman Collection
{
  "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\": \"test1234@transfi.com\",\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\": \"test1234@transfi.com\",\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 Session** → **Send**. The pre-request script computes `x-timestamp` and `x-signature` for you automatically — nothing else to configure.

### Response

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

<Callout icon="ℹ️" theme="info">
  ### **Session lifecycle.** A session can be **resolved by the widget exactly once** (it flips from `created` → `inprogress`), 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.
</Callout>

***

## 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 param                                    | New `/v2/ramp-session` field | Notes                                                                                                                                               |
| --------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `apiKey`                                                  | `x-api-key` **header**       | No longer in the URL — used to look up your org and HMAC secret.                                                                                    |
| `signature`                                               | `x-signature` **header**     | Was HMAC over the query string; now HMAC‑SHA256 over `timestamp + "." + body`.                                                                      |
| `view` (or `product`)                                     | `view`                       | `product` was the legacy alias.                                                                                                                     |
| `email`, `firstName`, `lastName`                          | same                         | —                                                                                                                                                   |
| `country`                                                 | `country`                    | ISO 3166-1 alpha-2.                                                                                                                                 |
| `fiatTicker`, `fiatAmount`                                | same                         | —                                                                                                                                                   |
| `cryptoTicker`, `cryptoNetwork`                           | same                         | —                                                                                                                                                   |
| `cryptoAmount`, `walletAddress`, `paymentCode`            | same                         | —                                                                                                                                                   |
| `kycToken` / `sumsubKycToken`                             | same                         | —                                                                                                                                                   |
| `quoteId`, `redirectUrl`, `partnerContext`, `merchantUrl` | same                         | —                                                                                                                                                   |
| —                                                         | `webhookUrl`                 | New — optional order webhook.                                                                                                                       |
| —                                                         | `phone` / `phoneCode`        | New — accepted at session creation, but **not yet returned when the widget resolves the session**, so it currently won't prefill. Known limitation. |
| —                                                         | `customerOrderId`            | New — accepted at session creation, but likewise **not yet returned on resolve**. Known limitation.                                                 |

> **The&#x20;**`signature`**&#x20;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`**,&#x20;**`phoneCode`**, and&#x20;**`customerOrderId`**&#x20;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

| Error                                                   | Status | Likely cause                                                                                                          | Fix                                                                                                                 |
| ------------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| `IP Not Configured`                                     | 403    | Your 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 whitelisted`                            | 403    | Your 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 org`          | 403    | No 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 header` | 401    | One or more required headers weren't sent.                                                                            | Verify all three headers are present on the request.                                                                |
| `Invalid timestamp format`                              | 401    | `x-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 attack`            | 401    | `x-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)                        | 401    | The 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>: ...`                                  | 400    | `webhookUrl`, `redirectUrl`, or `merchantUrl` failed URL validation.                                                  | Ensure the URL is a well-formed, externally-routable `http(s)` URL.                                                 |
| `Invalid or expired session`                            | 401    | The `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 `redirectUrl`s.                                    |
| `An order has already been created for this session`    | —      | The 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.