Guide

How to receive crypto payments: invoices vs deposit addresses

Receiving crypto comes down to one decision you make before you write any code: is this payment a discrete charge for a known amount, or an open-ended account someone funds whenever they like? Those two shapes map to two halfin primitives — invoices and static deposit addresses — and picking the wrong one is the most common reason a first integration feels harder than it should. This guide walks both: when to reach for each, how on-chain confirmation actually decides when money is yours, and the one signed webhook that tells your backend a payment has cleared.

01

Two ways to receive, and how to choose

Every crypto receiving flow is built from one of two primitives. An invoice is a request to pay a specific amount, with a locked rate and an expiry — the customer pays once, and the invoice resolves. A static deposit address is a persistent receive address tied to an account; anyone who has it can send to it any amount, any number of times, with no per-payment expectation. The first models a checkout; the second models a wallet or a funded account.

Reach for an invoice when you know the amount and want it tied to a thing — an order, a subscription cycle, a single bill. The invoice anchors that amount to fiat, locks the exchange rate when it activates, and gives you a clean expected-versus-received comparison so underpayment and overpayment are visible rather than lost. Reach for a static deposit address when the amount and timing are the customer's call — a trading account a user tops up over time, a per-customer receive address you display in their dashboard, a deposit flow where 'how much' is up to them.

If you are unsure, default to invoices. Most businesses are billing for something specific, and the invoice's rate lock and expected-amount tracking do work you would otherwise have to build yourself. Use a deposit address only when the open-ended, no-fixed-amount shape genuinely fits — and you can use both: a deposit address for ongoing account funding, invoices for the discrete charges within it.

InvoiceStatic deposit address
AmountFixed — you state it up frontOpen — the sender decides
RateLocked at activation, with an expiryNo quote; you receive whatever lands
LifetimeOne payment, then it resolves or expiresPersistent — reused across many deposits
Best forOrders, subscription cycles, single billsAccount funding, trading top-ups, open deposits
Under/overpaidTracked against the expected amountNot applicable — there is no expected amount
02

Path A — Receive a known amount with an invoice

When you are billing for something specific, create an invoice. You authenticate with a scoped API key and post the amount you want paid; halfin computes the payable crypto amount, locks the rate at activation, stamps an expiry, and hands back an invoice you present through hosted checkout or render yourself. You then wait for a signed webhook to tell you it cleared — covered in the confirmation and webhook sections below.

There are two ways to state the amount, and the difference matters. A fiat-anchored invoice names a fiat figure — '49.00 USD' — and the customer pays the crypto-equivalent at the locked rate; this is what you want when your prices live in dollars or euros and your accounting reconciles in fiat. A fixed-asset invoice names a crypto amount directly — '0.01 BTC' — where the currency field is the crypto code, not a fiat code; use this when the price genuinely is denominated in the asset itself. Pick one shape per invoice; do not mix a fiat currency code into the fixed-asset request.

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 existing invoice instead of billing the customer twice. The fiat-anchored create call below is the common case. The exact response fields live in the API reference at docs.thehalfin.com; store the returned invoice id against your order and present the checkout URL it carries.

# Fiat-anchored: bill 49.00 USD, customer pays the crypto-equivalent
# at the rate halfin locks when the invoice activates.
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 #4821",
    "idempotency_key": "order_4821"
  }'

# For a fixed-asset invoice instead, name the crypto amount and
# the crypto currency code (never a fiat code):
#   { "amount": "0.01", "currency": "BTC" }
#
# Either way the response carries the invoice id and a hosted
# checkout URL on checkout.thehalfin.com. Store the id, present the
# URL, and wait for the signed webhook. Full schema: docs.thehalfin.com.
03

Path B — Receive open-ended funds with a static deposit address

When the amount is the sender's decision, give them a static deposit address. It is a persistent receive address on a specific chain — a Tron address for TRC-20 USDT, an Ethereum address for ERC-20 USDC, a Bitcoin address for BTC — that you can show a customer once and reuse across every deposit they make. There is no quote and no expiry: whatever arrives on that address is credited to the account it belongs to.

The trade-off versus an invoice is that you give up the expected-amount machinery. Because nothing was billed, there is no underpaid or overpaid state — every inbound transfer is simply a credit for the amount that landed. That is exactly what you want for a funding flow and exactly wrong for a checkout, which is why the choice between the two primitives is the choice that shapes the rest of the integration.

One operational rule carries weight: an address belongs to a single chain. A Tron USDT address accepts TRC-20 USDT on Tron — it is not the same string as an Ethereum address and will not receive ERC-20 USDT, even though both are 'USDT'. When you display a deposit address, always display the network alongside it, because a customer who sends USDT on the wrong chain to an address that does not exist there can lose the funds. Match the address to the asset and network you intend to receive, and label it unambiguously in your UI.

  • A deposit address is persistent — display it once, reuse it across many deposits.
  • No quote, no expiry, no under/overpaid — every inbound transfer is a plain credit.
  • An address is bound to one chain; always show the network next to it.
  • Ideal for account funding and trading top-ups; wrong for a fixed-price checkout.
  • You learn about each deposit through the same signed credit webhook covered below.
04

When is a crypto payment actually yours? Confirmations and reorgs

A payment is not final the instant a customer's wallet says 'sent'. The transaction is first broadcast to the network's mempool, then included in a block, and only then does it begin accumulating confirmations as more blocks build on top of it. Until enough blocks have stacked up, the block holding your payment can still be replaced by a competing chain — a reorganization, or reorg — which would unwind the transaction as if it never happened. Crediting before that risk has passed is how you occasionally ship goods against a payment that disappears.

halfin handles this with a per-chain confirmation threshold and reorg-aware crediting. Each chain has its own pace and its own reorg risk, so the number of confirmations halfin waits for is tuned per chain — a few blocks on a fast chain, more on Bitcoin. A deposit is credited only once it has met that chain's threshold, and if a reorg unwinds a transaction that was already counted, the credit is reflected rather than silently kept. The amount halfin reports as credited is an amount that actually held.

For your integration the rule is simple: do not act on a first sighting. A payment seen in the mempool, or even one freshly in a block, is not yet a payment you should fulfil an order or release an account balance against. Wait for the platform to tell you the credit is final — which it does through a signed webhook, the subject of the next section. You do not implement confirmation counting or reorg handling yourself; you key your business logic off the final state halfin delivers.

05

The credit webhook: how your backend learns money arrived

Your application cannot see the blockchain, and polling the API on a timer is a poor substitute. The reliable signal that a payment cleared is a webhook: an HMAC-signed HTTP POST that halfin sends to your endpoint the moment it records a credit. For an invoice, that is invoice.paid (or invoice.underpaid / invoice.overpaid when the amount does not match the quote). For a static deposit address, a confirmed deposit raises balance.credited. Either way the event arrives after confirmations have met the chain's threshold, so receiving it is your cue that the funds held.

Verify before you trust. The endpoint URL is public the moment you register it, so anyone can POST arbitrary JSON to it — the signature is the only thing separating a genuine halfin event from a forged one. On receipt, 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. Compute the HMAC before any JSON middleware reserializes the body, or a re-encoded payload will fail an otherwise-valid signature. Treat an unsigned or mismatched request as hostile: return a 4xx and do nothing else. Never credit an account or fulfil an order off the payload alone.

Delivery is at least once, so build the handler to be idempotent: a redelivered event carries the same stable event id, and the second copy of a credit must be a no-op, not a double-credit. Acknowledge quickly with a 2xx once you have verified the signature and durably recorded the event, then push the slow work — fulfilment, emails, ledger writes — onto a background queue. If you want a definitive read, use the resource id from the event to fetch the canonical invoice or balance back from the API. The full event schemas, headers, and signing details are documented at docs.thehalfin.com.

EventFires whenWhat your handler does
invoice.paidAn invoice's full amount confirmed past the chain's thresholdFulfil the order tied to the invoice id, idempotently
invoice.underpaidA real payment arrived but is below the amount dueHold the order; surface the shortfall for follow-up
invoice.overpaidAn invoice settled with a surplus over the amount dueFulfil and flag the surplus for refund or credit
balance.creditedA confirmed deposit landed on a static address / accountCredit the account for the amount received, once only
06

Put it together: a minimal receiving flow

Whichever primitive you chose, the spine of the integration is the same: create or display the receiving surface, then wait for a verified webhook before you treat money as received. The steps below are the whole loop for a checkout built on invoices; a deposit-address flow drops the create-and-present step (the address already exists and is displayed) and listens for balance.credited instead.

Keep entitlement keyed off the signed event, never off a browser redirect. A customer who pays on their phone may never load your success page — the wallet app foregrounds and the tab is gone — but the webhook still arrives at your server independently. The redirect is a nicety for the customer; the webhook is the source of truth for your backend.

  • Create an invoice anchored to your amount (or display the customer's static deposit address with its network).
  • Present the checkout URL / address — let the customer pay from their own wallet on a supported chain.
  • Do nothing on first sighting; the platform waits out the per-chain confirmations and reorg risk for you.
  • Receive the signed webhook (invoice.paid or balance.credited); verify the HMAC over the raw body in constant time.
  • Map the event to your order or account by its id, apply the credit exactly once, return 2xx fast.
  • Defer fulfilment, email, and ledger writes to a background queue so deliveries are not held open.