Guide

How to accept Ethereum payments

You want a customer to pay you on Ethereum, and 'on Ethereum' splits into two cases the moment you start: native ETH, and ERC-20 tokens like USDC or USDT that ride the same network. The dollar amount you booked should be the dollar amount you keep, the price moves while the customer finds their wallet, and an Ethereum transaction is not final the instant it broadcasts — it has to land in a block and accumulate confirmations. This guide walks the whole path with halfin: create an invoice (priced in fiat or in a crypto amount), present a hosted checkout or a deposit address, wait for Ethereum's confirmation threshold, key your fulfilment off the signed invoice.paid webhook rather than the redirect, and — when fees on mainnet bite — route payers to the cheaper EVM L2s halfin also settles on.

01

What 'accept Ethereum' actually involves

Accepting a card is synchronous: you call an API, it returns approved or declined, you ship the order. Ethereum is not that. A payment is a transaction the customer signs and broadcasts to the network; it sits in the mempool, gets included in a block, and only becomes trustworthy once enough subsequent blocks make a reorganization unrealistic. None of that happens inside your application, and none of it returns synchronously from a single call.

There is a second fork that Bitcoin does not have. 'Ethereum' covers two kinds of value moving on the same chain: native ETH, the gas asset, and ERC-20 tokens — USDC, USDT, and other contracts — that live on top of it. A customer paying you in USDC is sending an ERC-20 token transfer and paying the gas in ETH; the asset that lands in your invoice is the token, not ETH. halfin treats each asset and network as its own settlement rail, so you decide which assets to accept rather than getting all of them by default.

Whichever asset they use, the integration has the same shape. You create an invoice that names what is owed and on which asset and network. You present a payment surface — a hosted page or a deposit address with the amount and a QR code. The customer pays from their wallet. The platform watches the chain, waits for the per-chain confirmation threshold with reorg-aware crediting, and tells your backend the moment the invoice is settled. You fulfil on that signal, not on the customer's word that they sent it.

02

Step 1 — Decide the asset and how you price it

Two questions sit at the front. First: which asset is owed — native ETH, or an ERC-20 token like USDC or USDT? Second: do you price the invoice in fiat (you book $49, the customer pays the equivalent) or fix it in the crypto amount (you ask for exactly 0.02 ETH, or exactly 49 USDC)? They are independent choices, and the request body is what encodes them.

Most businesses think in their home currency. Your catalogue is in dollars or euros, your accounting reconciles in fiat, and you do not want the booked amount to drift because ETH moved while the customer was opening their wallet. For that, create a fiat-anchored invoice: send an amount and a fiat currency, and halfin computes the payable crypto amount and locks that rate when the invoice activates. The customer pays the ETH-or-token equivalent of $49; you reconcile the clean $49. For a stablecoin like USDC, the fiat anchor and the token amount track each other closely, but the lock still protects you from the small drift and the network fee the customer might skim.

Sometimes the price genuinely is denominated in the coin — a fixed ETH amount a counterparty agreed to, or a flat token price. For that, create a fixed-asset invoice: send the crypto amount and the crypto currency code (ETH, or USDC, or USDT). The single most common mistake is the currency field on a fixed-asset invoice: it is the crypto code, never a fiat code. There is no such thing as an invoice whose currency is USD and whose amount is a coin amount — a fiat price is amount_fiat plus fiat_currency, a coin price is amount plus a crypto currency. Pick the model that matches what you actually booked.

You bookedUseRequest carriesCustomer pays
A fiat price ($49)Fiat-anchored invoiceamount_fiat + fiat_currencyThe ETH or token equivalent at the locked rate
A coin price (0.02 ETH)Fixed-asset invoiceamount + currency (ETH)Exactly that ETH amount
A token price (49 USDC)Fixed-asset invoiceamount + currency (USDC)Exactly that token amount on its network
03

Step 2 — Create the invoice

Invoicing is spec-first REST. You authenticate with a scoped API key, post the amount, and get back an invoice carrying its id, the payable crypto amount, an Ethereum deposit address, and a hosted checkout URL on checkout.thehalfin.com. Send the amount as a string — monetary values are strings end to end, never floats — and pass an idempotency key so a retried create call returns the same invoice instead of billing the customer twice.

The curl below creates a fiat-anchored invoice: you book $49 and the customer pays the equivalent at the rate locked on activation. To ask for a fixed coin amount instead, replace the body with the fixed-asset shape — amount plus the crypto currency code (ETH for native, USDC or USDT for a token) — and keep everything else the same. The same call works through the @halfin/sdk-merchant TypeScript client if your backend is in TypeScript. Field names and the full response schema live in the API reference at docs.thehalfin.com; this is the request shape and the moving parts, not an exhaustive field list.

# Fiat-anchored: you book USD, the customer pays the ETH or token equivalent.
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": "Order #1024",
    "idempotency_key": "00000000-0000-4000-8000-000000000001"
  }'

# Fixed native ETH instead — the customer owes exactly this coin amount:
#   -d '{ "amount": "0.02", "currency": "ETH", "description": "Order #1024" }'
#
# Fixed ERC-20 token (USDC) instead — exactly this token amount:
#   -d '{ "amount": "49.00", "currency": "USDC", "description": "Order #1024" }'
#
# The response carries the invoice id, the payable crypto amount, an Ethereum
# deposit address, and a hosted checkout URL on checkout.thehalfin.com. Store the
# id, send the customer to the URL, and wait for the signed invoice webhook.
# See docs.thehalfin.com for the full response schema.
04

Step 3 — Present a payment surface: hosted checkout or your own address

The create-invoice response gives you two ways to collect the payment, and you pick based on how much of the experience you want to own. The fastest path is hosted checkout: redirect the customer to the checkout URL on checkout.thehalfin.com, and halfin renders the Ethereum address, the exact payable amount, a scannable QR code, the countdown to expiry, and a live payment status that updates as the deposit is seen and confirmed. You build almost nothing and the page stays current with the chain on its own.

If the payment has to live inside your own product — a checkout step in your billing area, a desk that prefers its own UI — render it yourself from the same invoice object. The invoice already carries the Ethereum deposit address and the payable amount, so you show the address, render a QR for it, display the amount, and surface the countdown. You are responsible for keeping that view honest, which in practice means driving its state from the same webhook events covered in Step 5 rather than guessing on a timer.

One detail matters more on Ethereum than on Bitcoin: tell the customer which asset and network to send, plainly. The same deposit address receives native ETH and ERC-20 tokens, so a customer who means to pay USDC but sends ETH, or sends a token on the wrong network, produces a mismatch you would rather avoid. Make the asset, the amount, and the address unmissable and copy-safe — a customer who fat-fingers the amount produces an underpaid invoice, which is recoverable but is work. Always show a QR so phone wallets can pay without manual entry, and show the payment window so an expired quote re-issues rather than silently re-pricing.

  • Hosted checkout: redirect to the checkout URL; halfin renders address, amount, QR, countdown, and live status.
  • Self-rendered: read the deposit address and payable amount off the invoice and build the page; drive its state from webhooks.
  • State the asset and network clearly — the same address takes native ETH and ERC-20 tokens, so 'send USDC, not ETH' must be obvious.
  • Always show a QR for the address so phone wallets pay without manual entry.
  • Show the payment window — the quote has an expiry, and an expired invoice should re-issue, not silently re-price.
05

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

An Ethereum payment is not final when the customer's wallet says 'sent', and it is not final when the transaction first appears in the mempool. It becomes final as it is included in a block and subsequent blocks pile on top, making a reorg that unwinds it progressively less likely. halfin applies a per-chain confirmation threshold and credits reorg-aware: an invoice it reports as paid has settled under the chain's rules, not merely been seen on the network. The same waiting applies whether the asset was native ETH or an ERC-20 token transfer — a token deposit confirms on the same blocks as everything else on the chain.

For your logic this means one hard rule: do not release goods on first sight. The gap between a deposit being seen and the invoice reaching paid is real, and granting access the instant a transaction appears is how you occasionally ship an order against a payment that later gets reorged away. The platform absorbs the waiting and the reorg handling; your job is to key fulfilment off the final state, which Step 5 delivers as an event.

Two payment edge cases deserve a defined response. An underpaid invoice means a real but insufficient amount arrived — sometimes because the customer paid gas out of the same balance and sent slightly less, or sent a token amount that did not cover the quote — and the invoice records the shortfall so your back office can request the remainder or void it. An overpaid invoice records the excess the same way, which you can refund or credit. Decide your policy once and let the recorded state drive it, rather than discovering mismatches in a reconciliation report a week later.

Invoice stateWhat it meansWhat your code should do
Awaiting paymentInvoice live, amount and address shown, waiting before expiryShow address, amount, QR, and countdown — do not fulfil
Payment seenA matching ETH or token deposit is in the mempool / a block but under the thresholdTell the customer it is in flight — still do not fulfil
ConfirmingConfirmations are accumulating toward the chain's thresholdWait — crediting is reorg-aware and not yet final
PaidThreshold met; the amount is settled to your balanceFulfil the order (idempotently) and reconcile
ExpiredThe window elapsed before a sufficient payment arrivedRe-issue at the current rate if the customer still wants to pay
06

Step 5 — React to the invoice.paid webhook, not the redirect

When the invoice settles, the customer is usually returned to a success page — but that redirect can be missed. Someone pays from a phone wallet, the wallet app foregrounds, the browser tab is gone, and your success page never loads. If the redirect firing is your only signal that they paid, you will silently fail to fulfil an order that was actually settled. The redirect is a courtesy to the customer, not a source of truth for your backend.

The reliable signal is the webhook. halfin sends your server an HMAC-signed event when the invoice reaches paid, and that event arrives independently of whatever the customer's browser did. Verify it before you trust it: recompute the HMAC over the exact raw request bytes using your endpoint's signing secret, compare it to the signature header in constant time, and only then parse the body and act. The endpoint URL is public the moment you register it, so an unsigned or mismatched request is hostile — return a 4xx and do nothing. Checking the signature before acting is what stops a forged 'paid' callback from shipping a free order.

Once verified, look up the order by the identifier you attached at creation, fulfil it, and record that you did. Keep the handler idempotent — delivery is at least once, and a redelivered invoice.paid must not ship twice or credit twice; dedupe on the stable event id and make the second copy a no-op. Acknowledge with a 2xx quickly and push the slow work (email, fulfilment, ledger writes) onto a queue so a slow handler is not read as a failed delivery and retried. The canonical Ethereum-relevant events are listed below; invoice.paid is the one that ships the order, but the under/overpaid and late-deposit events are the ones that save you a support ticket.

EventMeaningTypical handler action
invoice.confirmingA deposit is seen and confirmations are accumulatingShow 'payment in flight' — do not fulfil yet
invoice.paidConfirmed past the chain's threshold; settled to your balanceFulfil the order, idempotently, by your attached id
invoice.underpaidA real but insufficient ETH or token amount arrivedHold; request the remainder or void per your policy
invoice.overpaidMore than the quote was receivedFulfil and flag the surplus for refund or credit
invoice.expiredThe window closed before a sufficient paymentLet the customer start a fresh invoice
invoice.late_depositA payment arrived after the invoice had expiredReconcile out of band — credit, refund, or re-bill
07

Step 6 — When mainnet fees bite, route payers to an L2

Ethereum mainnet gas is the one friction that surprises merchants. When the network is busy, the gas to send a payment can be a meaningful fraction of a small invoice, and a customer paying $20 in USDC on mainnet may balk at the fee on top. The lever is the network, not the asset: the same USDC and ETH-style value settles on EVM L2s where fees are a fraction of mainnet, and halfin runs gates on Base, Arbitrum, and Polygon alongside Ethereum.

You do not have to pick one for everyone. Enable the networks you are comfortable settling on and let the checkout present the choice, the same way you let a customer choose between paying ETH and paying a token. A practical default for small-ticket payments is to favor a low-fee route — USDC on Base, value on Arbitrum or Polygon — and keep mainnet available for customers whose funds already live there. The invoice and webhook flow is identical across these networks; only the rail the deposit lands on changes, and the asset matrix below is the real supported surface, not a longer aspirational list.

Two things stay constant when you add L2s. The asset-and-network confusion from Step 3 gets sharper — a customer must send USDC on the network the invoice expects, not the same token on a different chain — so be explicit about which network a given payment surface is collecting on. And confirmations still matter: each network applies its own per-chain threshold with the same reorg-aware crediting, so 'wait for invoice.paid' is the rule on Base and Arbitrum exactly as it is on mainnet.

NetworkAssets halfin settlesWhy a payer picks it
Ethereum (mainnet)ETH, USDC, USDT, and other ERC-20 tokensFunds already there; deepest liquidity
BaseETH-style value and USDCLow fees for USDC-denominated payments
ArbitrumNative L2 valueLow fees; common for EVM-native payers
PolygonNative L2 valueLow fees; widely held by retail payers
08

Step 7 — Operate it: test, secure the secret, watch deliveries

Before you go live, exercise the whole path against the sandbox. Create an Ethereum invoice, pay it, and watch your endpoint receive the confirming and paid events in order. If you accept tokens, test a token payment too — an ERC-20 transfer is a different code path on-chain than a native send, and you want to see a USDC deposit credit cleanly before a customer relies on it. Confirm your signature check passes for a genuine event and fails for a tampered one: flip a byte in the body and make sure you return a 4xx. The dry run is there to catch the two failure modes that only show up under real delivery — a body your framework re-parsed before you computed the HMAC, and a handler that is not idempotent under redelivery.

Treat the webhook signing secret like any other credential. Store it in your secret manager, never in source control, and rotate it if you suspect exposure. Use a scoped API key for invoice creation that is separate from any key that can move money out — a leaked read-or-invoice key should never be able to authorize a payout. Keeping those scopes apart is cheap and is the difference between a leaked credential being an annoyance and being an incident.

In production, keep an eye on delivery health. Log the event id and the verification result for every webhook you receive, so when finance asks why an order did not fulfil you can point to the exact event and whether it was received, verified, and acted on. An endpoint that starts returning non-2xx responses, or one that is briefly unreachable, will see redeliveries pile up — which is fine because the events are idempotent and the ids are stable, but only if you built the handler that way in Step 5.

  • Dry-run the full path on the sandbox: create, pay, watch confirming then paid arrive — test a token payment, not just native ETH.
  • Compute the HMAC over the raw bytes — re-parsed bodies fail an otherwise-valid signature.
  • Keep the webhook signing secret in a secret manager; rotate on suspected exposure.
  • Scope the invoicing key separately from any payout-capable key.
  • Log event id + verification result per request so missed fulfilments are explainable.