What you can accept on Solana, and the SPL wrinkle
Solana is a single-chain account model, not an EVM clone, so the asset list is shaped by it. There is one native coin — SOL — and everything else is an SPL token: a mint deployed on Solana with its own address, held in a per-owner token account rather than directly on the wallet. halfin runs a real Solana gate, so it watches both native SOL transfers and the SPL token transfers that matter for stablecoin billing. For most merchants the stablecoins are the headline: USDC and USDT both have first-party SPL mints on Solana, and a payer holding either settles a fiat-anchored invoice directly — no bridging, no wrapping, no detour through another chain.
The wrinkle worth understanding before you integrate is the SPL token account. On Solana a wallet does not hold USDC the way it holds SOL; it holds a per-mint token account that has to exist before tokens can land in it. A hand-rolled payment flow that forgets this trips over it — the canonical gotcha of accepting SPL tokens. You do not have to solve it: halfin's gate and the hosted checkout handle the token-account detail for you, so a payer who keeps USDC in a Phantom or Solflare wallet pays in one tap and you never touch the mechanics. It is called out here only so you understand why a self-rolled Solana flow is harder than a Bitcoin one, and why letting halfin own the wallet-side detail is the cheaper path.
One thing to keep straight regardless of how you build: the same ticker can live on more than one chain. USDC on Solana is a different mint from USDC on Ethereum or Base, and a payer has to send the one your invoice is scoped to. Sending the right token on the wrong chain is the most common way a self-directed crypto payment goes missing. halfin presents the network alongside the amount and address so the customer is told plainly which rail to use — heading that mistake off is one of the things the checkout exists to do.
- SOL — Solana's native coin, paid from the customer's main wallet balance.
- USDC on Solana — a dollar-denominated stablecoin, issued as an SPL mint.
- USDT on Solana — the SPL build of Tether, for payers who already hold it there.
- SPL token accounts are handled for you — halfin's gate and checkout own that detail.
Step 1 — Decide how you price: fiat-anchored or fixed asset
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 amount you booked to drift because SOL moved while the customer was opening their wallet. For that case, create a fiat-anchored invoice: you send a fiat amount and a fiat currency, and halfin computes the payable asset amount and locks that rate when the invoice activates. The customer pays the SOL (or USDC, or USDT) equivalent of $25; you reconcile the clean $25.
Sometimes the price genuinely is denominated in an asset — a fixed SOL fee, a token-priced product, a payment a counterparty agreed to in coin. For that, create a fixed-asset invoice: you send the asset amount and the crypto currency code, and the invoice asks for exactly that. There is no fiat conversion to lock because there is no fiat anchor; the customer owes the coin amount you named.
The request bodies differ in exactly one way, and getting it right is the single most common mistake. A fiat-anchored request carries the fiat amount and the fiat currency. A fixed-asset request carries the coin amount and a crypto currency code. The currency field on a fixed-asset invoice is the crypto code (SOL, USDC, USDT), never a fiat code — there is no such thing as an invoice whose currency is USD and whose amount is a coin amount. Stablecoins on Solana make the fiat-anchored choice especially clean: a USD-anchored invoice paid in Solana USDC moves a near-identical amount, with the rate lock absorbing only the small spread. SOL, being volatile, is where the activation-time lock earns its keep.
| You booked | Use | Request carries | Customer pays |
|---|---|---|---|
| A fiat price ($25) | Fiat-anchored invoice | amount_fiat + fiat_currency | The SOL / USDC / USDT equivalent at the locked rate |
| A coin price (0.5 SOL) | Fixed-asset invoice | amount + currency (the crypto code) | Exactly that asset amount |
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 asset amount, a Solana 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 $25 and the customer pays the Solana equivalent at the rate locked on activation, choosing SOL or an SPL stablecoin on the payment page. To ask for a fixed coin amount instead, replace the body with the fixed-asset shape 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 Solana 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": "25.00",
"fiat_currency": "USD",
"description": "Wallet top-up",
"idempotency_key": "00000000-0000-4000-8000-000000000001"
}'
# Fixed asset instead — the customer owes exactly this coin amount:
# -d '{ "amount": "0.5", "currency": "SOL", "description": "Wallet top-up" }'
#
# The response carries the invoice id, the payable asset amount, a Solana
# deposit address, and a hosted checkout URL on checkout.thehalfin.com. The
# customer picks Solana on the checkout and pays in SOL or an SPL
# stablecoin (USDC / USDT). 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 Solana 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 handles the SPL token-account nuance that trips up hand-rolled flows.
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 Solana 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 — and for SPL payments it means being clear which mint and network the customer must send to.
Whichever surface you choose, the customer-facing essentials are the same: the exact amount, the deposit address, the network and asset named unambiguously, a QR code so they can pay from a phone wallet without copy-paste errors, and a visible payment window. Make the amount and address unmissable and copy-safe — a customer who fat-fingers the amount produces an underpaid invoice, which is recoverable but is work you would rather avoid. Naming the network plainly is doubly important on Solana, where the same stablecoin ticker also exists on other chains.
- Hosted checkout: redirect to the checkout URL; halfin renders address, amount, network, 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.
- Always name the network and asset (e.g. USDC on Solana) so the customer sends on the right rail.
- Always show a QR for the address so phone wallets can pay without manual entry.
Step 4 — Wait for confirmations before you treat it as paid
Solana is fast, but a payment is still not final the instant a customer's wallet says 'sent'. A transfer is seen on the network, accumulates confirmations, and only then is trustworthy. halfin applies a per-chain confirmation threshold and credits reorg-aware: an invoice it reports as paid has settled under Solana's rules, not merely been seen on the network. The wait is short here — seconds, not the minutes Bitcoin needs — but it is real, and a fast confirmation is still a confirmation.
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 brief on Solana, but 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. The speed of Solana is a gift to the customer experience, not a license to skip confirmation.
Two payment edge cases deserve a defined response. An underpaid invoice means a real but insufficient amount arrived — a stale quote, an exchange withdrawal fee — and the invoice records the shortfall against the quote 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 toward the next purchase. 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, asset amount and Solana address shown, waiting before expiry | Show address, amount, network, QR, and countdown — do not fulfil |
| Payment seen | A matching deposit is on the network but under the threshold | Tell the customer it is in flight — still do not fulfil |
| Confirming | Confirmations are accumulating toward Solana'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 events relevant to a Solana payment 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 Solana deposit is seen and confirmations are accumulating | Show 'payment in flight' — do not fulfil yet |
| invoice.paid | Confirmed past Solana's threshold; settled to your balance | Fulfil the order, idempotently, by your attached id |
| invoice.underpaid | A real but insufficient 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 |
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 on the event.
const event = JSON.parse(req.body.toString("utf8"));
if (event.type === "invoice.paid") {
// fulfil the order tied to event.data.id — idempotently, since this
// event can be redelivered. A Solana invoice.paid settles fast, but
// it has still cleared the chain's confirmation threshold.
}
// Acknowledge fast; defer slow work to a queue.
return res.status(200).send("ok");
},
);
// See docs.thehalfin.com for the canonical event list and the full data schema.Step 6 — Operate it: test, secure the secret, watch deliveries
Before you go live, exercise the whole path against the sandbox. Create a Solana invoice, pay it in SOL or an SPL stablecoin, and watch your endpoint receive the confirming and paid events in order. 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 point of the dry run is to catch the two failure modes that only show up under real delivery: a body that 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 in SOL or SPL, watch confirming then paid arrive.
- 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.