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.
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 booked | Use | Request carries | Customer pays |
|---|---|---|---|
| A fiat price ($49) | Fiat-anchored invoice | amount_fiat + fiat_currency | The ETH or token equivalent at the locked rate |
| A coin price (0.02 ETH) | Fixed-asset invoice | amount + currency (ETH) | Exactly that ETH amount |
| A token price (49 USDC) | Fixed-asset invoice | amount + currency (USDC) | Exactly that token amount on its network |
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.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.
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 state | What it means | What your code should do |
|---|---|---|
| Awaiting payment | Invoice live, amount and address shown, waiting before expiry | Show address, amount, QR, and countdown — do not fulfil |
| Payment seen | A matching ETH or token deposit is in the mempool / a block but under the threshold | Tell the customer it is in flight — still do not fulfil |
| Confirming | Confirmations are accumulating toward the chain's threshold | Wait — crediting is reorg-aware and not yet final |
| Paid | Threshold met; the amount is settled to your balance | Fulfil the order (idempotently) and reconcile |
| Expired | The window elapsed before a sufficient payment arrived | Re-issue at the current rate if the customer still wants to pay |
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.
| Event | Meaning | Typical handler action |
|---|---|---|
| invoice.confirming | A deposit is seen and confirmations are accumulating | Show 'payment in flight' — do not fulfil yet |
| invoice.paid | Confirmed past the chain's threshold; settled to your balance | Fulfil the order, idempotently, by your attached id |
| invoice.underpaid | A real but insufficient ETH or token amount arrived | Hold; request the remainder or void per your policy |
| invoice.overpaid | More than the quote was received | Fulfil and flag the surplus for refund or credit |
| invoice.expired | The window closed before a sufficient payment | Let the customer start a fresh invoice |
| invoice.late_deposit | A payment arrived after the invoice had expired | Reconcile out of band — credit, refund, or re-bill |
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.
| Network | Assets halfin settles | Why a payer picks it |
|---|---|---|
| Ethereum (mainnet) | ETH, USDC, USDT, and other ERC-20 tokens | Funds already there; deepest liquidity |
| Base | ETH-style value and USDC | Low fees for USDC-denominated payments |
| Arbitrum | Native L2 value | Low fees; common for EVM-native payers |
| Polygon | Native L2 value | Low fees; widely held by retail payers |
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.