DEV Community

scarab pay
scarab pay

Posted on

Issue USDT-funded Visa cards to your users with one REST API

If you run a crypto exchange, a wallet, a freelancer-payout platform or a neobank in an emerging market, you've probably hit the same wall: your users hold stablecoins, but the world they pay in runs on Visa and Mastercard. Getting a card program means a BIN sponsor, a processor, a compliance stack and six months of calls.

ScarabPay Business flattens that into one REST API: you top up a USD wallet with USDT or USDC, register your cardholders, and issue prepaid USD virtual cards you fully control (issue, recharge, freeze, unfreeze, terminate, transaction history). ScarabPay isn't a bank; cards are issued through licensed partners, and you never touch a PAN or CVV.

This post walks through the whole flow with real requests. Base URL is https://business.scarabpay.com/api/v1, and everything below runs against sandbox keys you get the moment you create an account.

1. Authentication: HMAC, not bearer tokens

Every request is signed with HMAC-SHA256 using your key's secret (shown once at creation). Four headers:

Header Value
X-Api-Key your public key id
X-Timestamp Unix seconds (UTC), ±300 s
X-Nonce UUID v4, single-use
X-Signature base64(hmac_sha256(secret, canonical_string))

The canonical string is seven lines joined with \n:

METHOD
PATH                     ← full path, e.g. /api/v1/cards
QUERY_STRING             ← sorted k=v pairs, "" if none
X-Api-Key
X-Timestamp
X-Nonce
hex(sha256(raw_body))    ← sha256("") for empty bodies
Enter fullscreen mode Exit fullscreen mode

A small Node helper does the whole job:

import crypto from "node:crypto";

const BASE = "https://business.scarabpay.com";
const KEY_ID = process.env.SCARAB_KEY_ID;
const SECRET = process.env.SCARAB_SECRET;

export async function scarab(method, path, body = null, query = {}) {
  const raw = body ? JSON.stringify(body) : "";
  const ts = Math.floor(Date.now() / 1000).toString();
  const nonce = crypto.randomUUID();

  const qs = Object.keys(query).sort()
    .map(k => `${encodeURIComponent(k)}=${encodeURIComponent(query[k]).replace(/%20/g, "+")}`)
    .join("&");

  const canonical = [
    method.toUpperCase(),
    `/api/v1${path}`,
    qs,
    KEY_ID,
    ts,
    nonce,
    crypto.createHash("sha256").update(raw).digest("hex"),
  ].join("\n");

  const sig = crypto.createHmac("sha256", SECRET).update(canonical).digest("base64");

  const res = await fetch(`${BASE}/api/v1${path}${qs ? `?${qs}` : ""}`, {
    method,
    headers: {
      "Content-Type": "application/json",
      "X-Api-Key": KEY_ID,
      "X-Timestamp": ts,
      "X-Nonce": nonce,
      "X-Signature": sig,
      ...(body ? { "Idempotency-Key": crypto.randomUUID() } : {}),
    },
    body: raw || undefined,
  });
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

Two things worth noticing. The signature covers the full path including /api/v1 (the most common signing mistake). And every mutating request carries an Idempotency-Key: retry with the same key and you get the same result, never a second card or a double charge.

2. Fund the wallet with stablecoins

Ask for a permanent deposit address on the network you use. One address per network, reused forever:

const { data: addr } = await scarab("POST", "/deposit-addresses", { network: "trc20" });
// { id, network: "trc20", asset: "USDT", address: "T...", memo: null, created_at }
Enter fullscreen mode Exit fullscreen mode

Send USDT to it from your treasury. Confirmed deposits show up under GET /deposits and credit your USD wallet. erc20 and bep20 are also supported.

3. Register the cardholder

Cards are issued to a customer, your end user, who goes through KYC once:

const { data: customer } = await scarab("POST", "/customers", {
  first_name: "Amina",
  last_name: "Haddad",
  email: "amina@example.com",
  dial_code: "+971",
  phone_number: "501234567",
  date_of_birth: "1994-03-12",
  id_type: "passport",
  id_number: "N1234567",
  line1: "12 Marina Walk",
  city: "Dubai",
  state: "Dubai",
  postal_code: "00000",
  country: "AE",
  occupation: "Designer",
  employment_status: "employed",
  account_purpose: "online purchases",
  annual_salary: "50000",
  expected_monthly_volume: "2000",
  terms_of_service_accepted: true,
});
// 202 Accepted — verification runs async; you'll get a webhook when it's done
Enter fullscreen mode Exit fullscreen mode

The response is 202, not 201: verification happens asynchronously and you're notified by webhook. If a document is rejected you re-submit with POST /customers/{id}/kyc instead of creating a new customer.

4. Issue the card

Money is always integer minor units (cents), so 2500 is $25.00:

const { data: card } = await scarab("POST", "/cards", {
  customer_id: customer.id,
  amount_minor: 2500,          // initial load, debited from your wallet
  // product_id: optional — omit to use your default product
});
// { id, status: "active", masked_pan: "4••• •••• •••• 1234", last4, expiry, balance_minor: 2500, ... }
Enter fullscreen mode Exit fullscreen mode

That's it. You now have a live Visa/Mastercard virtual card your user can add to Apple Pay, use on Amazon, pay for ads, whatever. The card's PAN and CVV are delivered to the cardholder through the secure reveal flow; they are never returned by this API and never stored by the platform, which keeps your PCI scope at zero.

5. Run the lifecycle

await scarab("POST", `/cards/${card.id}/recharge`,  { amount_minor: 10000 }); // +$100
await scarab("POST", `/cards/${card.id}/freeze`);
await scarab("POST", `/cards/${card.id}/unfreeze`);
await scarab("POST", `/cards/${card.id}/terminate`);                          // refunds remaining balance to your wallet

const { data: txs } = await scarab("GET", `/cards/${card.id}/transactions`, null, { limit: 50 });
// cursor-paginated; follow `next_cursor`
Enter fullscreen mode Exit fullscreen mode

Card states are explicit (pending, active, frozen, terminated, expired, failed), and an operation that doesn't fit the current state returns a typed 422 with a code you can branch on, not a generic error.

6. Webhooks

Authorizations, settlements, declines, KYC results and deposits arrive as webhooks signed with HMAC-SHA256 and retried with backoff. Verify the signature, respond 2xx, and treat the payload as the source of truth; don't poll.

Why this design

A few choices that make day-two life easier:

  • Insert-only, balanced ledger. Every cent is traceable; nothing is ever updated in place.
  • Idempotency on every write. Network flakiness can't create duplicate cards.
  • 14 endpoints, no SDK required. The OpenAPI 3 spec is public, so generate a client in your language in one command.
  • Stablecoin-native funding. No bank wires, no FX desk, no waiting three days for a top-up to land.

Try it

  1. Create an account at business.scarabpay.com and grab sandbox keys.
  2. Download the spec: /developers/api/openapi.yaml.
  3. Read the reference with a worked, reproducible signature example: /developers/api.

Questions about a specific integration (exchange, payout platform, MENA fintech)? Drop them in the comments; I'm the founder and I answer.

Top comments (0)