Guide

How to accept USDC payments

USDC is a dollar-pegged stablecoin, which makes it the path of least resistance for a business that prices in dollars and wants to get paid in crypto without riding the volatility. The catch is that the same coin lives on several networks — the USDC your customer holds on Ethereum, on Base, and on Solana are different on-chain assets that travel on different rails and cannot be sent across by address alone. This guide shows how to take USDC on all three with halfin: anchor the amount to fiat, let the customer pay on the network they hold, and treat a signed webhook — not the on-screen 'sent' — as the moment the money is real.

01

What 'accept USDC' actually means across three networks

Before any code, get the mental model straight, because it is the part that trips people up. USDC is issued natively on more than one chain, and halfin's gates run it on three: Ethereum as an ERC-20 token, Base (an Ethereum L2) as an ERC-20 token, and Solana as an SPL token. A customer who holds USDC on Solana cannot pay it to an Ethereum address, and vice versa — the networks are isolated, and a deposit address is only valid on the chain it was issued for. 'Accepting USDC' is really accepting USDC-on-Ethereum, USDC-on-Base, and USDC-on-Solana, each with its own address and its own confirmation behavior.

What differs between the three is cost and speed, not the dollar value. A USDC payment on Ethereum mainnet carries the highest network fee and the slowest finality; the same payment on Base or Solana is cheaper and faster for the customer. That is why you usually want to offer more than one — a customer paying a small invoice will balk at Ethereum gas, while a customer whose funds already sit on Ethereum would rather not bridge. You enable the networks you are comfortable settling on, and the customer picks at pay time.

What stays identical is the amount you book. Because you anchor the invoice to a fiat figure, one US dollar of USDC is one US dollar of USDC regardless of which network it rode in on. Your reconciliation does not branch by chain; it branches by invoice, and the invoice carries both the dollar amount you billed and the network the payment settled on.

NetworkUSDC token standardTrade-off for the customer
EthereumERC-20Highest network fee, slowest finality — fine for larger payments from funds already on Ethereum
BaseERC-20 (Ethereum L2)Low fee, fast — a good default for EVM-native payers
SolanaSPLLow fee, fast finality — a good default for Solana-native payers
02

Step 1 — Decide which USDC networks you will settle on

Start with a deliberate choice rather than enabling everything by reflex. Each network you accept is a balance you will hold and reconcile, so pick the set that matches your customers. If your audience is EVM-native, Ethereum plus Base covers most of them and lets the cost-sensitive ones avoid mainnet gas. If you serve a Solana-heavy audience, add Solana. There is no penalty for offering all three — the fiat anchor keeps the accounting uniform — but enable a network because customers will use it, not because it exists.

Whatever you enable, present the choice to the customer at pay time instead of guessing for them. A single invoice can offer USDC on more than one network; the customer selects the one they hold, and halfin shows the address and the exact token amount for that network. You are not building three separate checkouts — you are building one invoice that knows which rails you accept.

  • Accept a USDC network because your customers hold funds there, not for completeness.
  • Ethereum + Base suits an EVM-native audience; add Solana for Solana-native payers.
  • Let the customer choose the network on the checkout — do not hard-pin one for them.
  • Every enabled network is a balance you will hold and reconcile; keep the set intentional.
03

Step 2 — Create a fiat-anchored invoice

Price the invoice in the currency you actually bill in, and let halfin compute the USDC the customer pays. You send a fiat amount and a fiat currency — `amount_fiat` and `fiat_currency`, both strings — and halfin quotes the equivalent USDC and locks that quote when the invoice activates. With a dollar-pegged stablecoin the quoted amount is close to the dollar figure by design, but the lock still matters: it pins one concrete number the customer must send, with a bounded expiry, so there is no drift and no ambiguity about whether they paid enough.

Send monetary values as strings, never as floats — money is a string end to end in this API. Pass an `idempotency_key` field in the request body so a retried create call from your backend returns the same invoice instead of billing the customer twice. Attach your own order or cart identifier in the request (see the docs for the field) so the webhook that comes back later maps straight to the order without a side lookup. The call below creates a $49.00 invoice; the response carries the invoice id and a hosted checkout URL on checkout.thehalfin.com.

Do not put `"currency": "USD"` on an invoice and expect a USDC payment — `currency` is a crypto code, and a fiat-anchored invoice uses `amount_fiat` + `fiat_currency` instead. The full request and response schema, including how to scope the invoice to specific assets and networks, lives in the API reference at docs.thehalfin.com; treat the snippet here as the shape, not the complete field list.

curl -X POST https://api.thehalfin.com/api/v1/invoices \
  -H "X-API-Key: $HALFIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "amount_fiat": "49.00",
    "fiat_currency": "USD",
    "description": "Pro plan — order 7741",
    "idempotency_key": "order_7741"
  }'

# halfin quotes the USDC amount, locks the rate at activation, and
# returns an invoice id plus a hosted checkout URL on checkout.thehalfin.com.
# The customer chooses USDC on Ethereum, Base, or Solana at pay time.
# See docs.thehalfin.com for the full request and response schema.
04

Step 3 — Present the payment surface

With the invoice created you need to get the customer to somewhere they can pay. Two paths sit on top of the same invoice. Redirect to hosted checkout when you want the least integration work: the create-invoice response carries a checkout URL on checkout.thehalfin.com, you send the customer there, and halfin renders the network picker, the USDC address, the exact token amount, the QR code, and a live status. Render self-hosted checkout when the payment screen has to live inside your own product — same invoice, same API, but you build and own the page.

Either way, the customer's flow is: pick the USDC network they hold, see the address and token amount for that network, send the payment from their wallet, and watch the status move. Because each network has its own address, it is critical that the customer sends on the network they selected — USDC sent on Solana to an Ethereum address is lost. Hosted checkout handles this by only ever showing the address for the chosen network; if you build your own surface, make the selected network unmistakable so a customer cannot send on the wrong rail.

05

Step 4 — Wait for confirmation before you treat it as paid

A USDC transfer is not done the instant the wallet says 'sent'. The transaction has to land in a block and accumulate enough confirmations that a chain reorganization is no longer a realistic risk, and halfin applies a per-chain confirmation threshold with reorg-aware crediting. The wait is short on Base and Solana and longer on Ethereum mainnet, but it is real on all three — an invoice halfin reports as paid has settled under that chain's rules, not merely appeared in the mempool. Do not release goods or grant access on first sight of a transaction.

Two amount edge cases deserve a defined policy because they happen with real customers. An underpaid invoice received a genuine USDC payment that fell short of the quote — often because the customer's wallet quoted the amount before a fee, or they mistyped — and the invoicing layer records the shortfall against the invoice rather than discarding it, so you can request the remainder or void the order. An overpaid invoice records the surplus the same way, which you can refund or credit. Decide your policy once and let the recorded state drive it; do not silently fulfill on an underpayment.

Invoice eventWhat it meansWhat your handler does
invoice.confirmingInvoice is live with a locked USDC quote and an expiryShow the network, address, and amount — do not fulfill yet
invoice.paidConfirmed past the chain's threshold; the billed fiat amount is settledFulfill the order (idempotently)
invoice.underpaidA real USDC payment arrived but is below the amount dueHold the order; request the remainder or void — do not fulfill
invoice.overpaidSettled with a surplus over the amount dueFulfill and flag the surplus for refund or credit
invoice.expiredThe quote window closed before sufficient paymentRe-issue the invoice with a fresh quote if the customer still wants to pay
06

Step 5 — Confirm payment from a signed webhook, not the redirect

This is the step that separates a reliable integration from one that silently drops paid orders, so be strict about it. When the invoice resolves, the customer is returned to your site — but that redirect can be missed. They pay on their phone, the wallet app takes the foreground, the browser tab is gone, and your success page never loads. If the redirect is your only signal that they paid, you will fail to fulfill an order that was actually settled.

The reliable signal is the webhook. halfin POSTs your server an HMAC-signed event when the invoice reaches `invoice.paid`, and that event arrives independently of whatever the customer's browser did. Verify the signature before you trust the body: recompute the HMAC over the exact raw request bytes — before any JSON parsing or framework middleware reserializes them — and compare it to the signature header in constant time. Only after the comparison passes do you deserialize and act. An unsigned or mismatched request is not a halfin event; return a 4xx and do nothing, because your webhook URL is public and anyone can POST forged JSON to it.

Once verified, map the event to your order by the identifier you attached in Step 2, fulfill it, and record that you did. Keep the handler idempotent: delivery is at least once, so a redelivered `invoice.paid` carries the same stable event id and must not fulfill the order or email the customer twice. Acknowledge with a 2xx quickly and push slow work — fulfillment, email, ledger writes — onto a background queue, so a slow handler is not read as a failed delivery and retried. The full event list and envelope schema are in the docs; the canonical USDC events you will act on are below.

import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();
const SIGNING_SECRET = process.env.HALFIN_WEBHOOK_SECRET!;

// Capture the RAW body — the HMAC must be computed over the exact bytes
// received, not over a re-serialized JSON object.
app.post(
  "/webhooks/halfin",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const signature = req.header("x-halfin-signature") ?? "";
    const expected = createHmac("sha256", SIGNING_SECRET)
      .update(req.body) // req.body is a Buffer here
      .digest("hex");

    const a = Buffer.from(signature);
    const b = Buffer.from(expected);
    if (a.length !== b.length || !timingSafeEqual(a, b)) {
      return res.status(401).send("invalid signature");
    }

    // Only now is it safe to parse and act.
    const event = JSON.parse(req.body.toString("utf8"));
    if (event.type === "invoice.paid") {
      // Look up the order by your own id on the invoice, then fulfill it
      // exactly once — a redelivered event carries the same event id.
    }

    // Acknowledge fast; defer slow work to a queue.
    return res.status(200).send("ok");
  },
);
07

Step 6 — Reconcile and settle the USDC you collected

Paid invoices accrue to a balance, and because every invoice carries the fiat figure you billed, reconciliation is mechanical: each confirmed invoice maps back to the dollar amount you started with, matched by the asset (USDC) and the network it settled on. You are not eyeballing a rate screen at month-end — the dollar number was the source of truth the whole way through. Pull invoices by your own order identifier or by the invoice id you stored, and the on-chain detail is attached to each one.

On the settlement side, the USDC sitting in your balance is yours to convert or pay out within the platform — for example, rebalancing across networks or consolidating into a single stablecoin position with balance conversion. halfin is a payment rail, not a fiat off-ramp: it does not cash USDC out to a bank account, so treat the balance as an on-chain treasury you manage, not a checking account. A customer refund flows back out through refunds. The shape is symmetric: invoices and webhooks bring USDC in, conversions and payouts move it, and both halves reconcile through the same signed-event stream.