The shape: a payment module plus a webhook, not a plugin
Get the architecture right before touching code, because it is what keeps the integration correct. halfin does not ship a packaged PrestaShop module, so you are building a thin custom payment module that talks to the public REST API. That is a feature, not a gap: you control the flow, you own the secret, and there is no third-party plugin to audit or keep updated against PrestaShop releases.
The flow has exactly two halves. The front half runs in the customer's browser: PrestaShop renders your payment option at checkout, the customer picks it, your module creates a halfin invoice for the cart total and redirects them to halfin's hosted checkout to pay in the asset they hold. The back half runs server-to-server, with no browser involved: when the on-chain payment confirms, halfin POSTs a signed webhook to your shop, and your handler advances the PrestaShop order state.
The single most important rule is that these two halves are separate. The redirect back from checkout tells you the customer returned; it does not tell you they paid. Order fulfilment hangs off the webhook, which is the only trustworthy signal that money actually settled on-chain. Treat the redirect as cosmetic and the webhook as authoritative, and the integration is sound.
- No official halfin PrestaShop module — you build a small custom payment module against the public API.
- Front half: render the method, create an invoice on order placement, redirect to hosted checkout.
- Back half: a signed webhook confirms payment and drives the PrestaShop order state.
- Never mark an order paid from the redirect — only from a verified invoice.paid webhook.
Before you start: keys, a webhook endpoint, and an order state
Three pieces of setup live outside PrestaShop. First, an API key, created in the halfin dashboard and scoped to invoicing — your storefront only ever creates invoices, so it should never hold a payouts-scoped key. Store it as a shop configuration value (PrestaShop's Configuration store), not in a committed file. Second, a webhook endpoint registered in the dashboard, pointing at a public URL on your shop, with its signing secret saved alongside the API key. That secret is what your handler uses to prove an incoming event is genuinely from halfin.
Third, decide how you want PrestaShop to model an in-flight crypto payment. Crypto settles asynchronously — the customer pays, then the network confirms over several blocks — so the order is not paid the instant they leave checkout. The clean approach is a dedicated order state, for example Awaiting crypto payment, that your module sets when it creates the invoice. The order sits there until the webhook moves it to PrestaShop's built-in Payment accepted state. You can add that custom state once via the Statuses screen or in your module's install() method.
Keep your fiat anchor explicit. PrestaShop carries the cart total in the shop currency (say EUR or USD), and that is exactly what halfin invoicing is built to anchor on: you bill the fiat figure and the customer settles the equivalent in crypto, with the rate locked when the invoice goes live. You are not converting anything in PHP — you hand halfin the fiat amount and currency and let it pin the payable asset amount.
Step 1 — Render the payment option at checkout
PrestaShop discovers payment methods through the hookPaymentOptions hook on a module that registers the paymentOptions hook (the modern PaymentModule / PrestaShop 1.7+ path). Your module returns a PaymentOption describing the method the customer sees — a label like "Pay with crypto (USDT, USDC, BTC, ETH)" and the controller action that runs when they confirm. Nothing about halfin happens yet at this stage; you are only adding a choice to the payment step.
Keep the label honest and specific. List the assets you actually accept so the customer knows before they commit, and avoid implying instant settlement — crypto confirms over a short window, and your order state will reflect that. The actual invoice is created one step later, when the customer selects this option and confirms, so that you are not minting invoices for carts that never check out.
Step 2 — Create a halfin invoice when the order is placed
When the customer confirms the crypto option, your module's controller does three things in order: read the cart total and currency, create the PrestaShop order in your Awaiting crypto payment state with validateOrder(), then create a halfin invoice for that order. Doing validateOrder() first gives you a real PrestaShop order id you can carry into the invoice, so the webhook can find its way back to the exact order later.
The create call is a single authenticated POST. Send the fiat amount as a string and the fiat currency from the cart, mark the invoice deferred so the customer picks the asset on the hosted page, and pass an idempotency_key so a double-submitted checkout or a retried request never creates two invoices for one order. Derive that key from something stable and unique to the order — the PrestaShop order reference is ideal, because re-deriving it yields the same key. The description and your order reference give you a human-readable tie-back in the dashboard.
Crucially, idempotency_key is a snake_case field in the JSON body — not an HTTP header. The only request headers you send are X-API-Key and Content-Type. The example below uses curl for clarity; in the module you make the same request with PHP's cURL or Guzzle and read the hosted-checkout URL out of the response.
# Create one invoice per PrestaShop order. The cart total is the fiat anchor;
# the customer settles the equivalent in the asset they hold on hosted checkout.
# idempotency_key is a BODY field (snake_case), keyed off the order reference,
# so a resubmitted checkout never creates a second invoice for the same order.
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": "129.00",
"fiat_currency": "EUR",
"deferred": true,
"description": "PrestaShop order CQANKLKLP — replace with your order reference",
"idempotency_key": "prestashop-order-CQANKLKLP",
"redirect_url": "https://your-shop.example.com/order-confirmation?id_cart=1234"
}'
# The response carries the hosted-checkout URL and the invoice id. Persist the
# invoice id against the PrestaShop order, then redirect the customer to the
# checkout URL. See docs.thehalfin.com for the full response schema.Step 3 — Redirect to hosted checkout and bring the customer back
Take the hosted-checkout URL from the invoice response and redirect the customer's browser to it. halfin renders the payment page — the address, the exact payable asset amount, the network, a QR code, and a countdown to expiry — so you do not build or maintain any of that UI. The customer pays from their wallet on whichever supported network they hold value, and halfin handles matching the deposit, counting confirmations, and the underpaid or overpaid edge cases.
Set redirect_url on the invoice to the page the customer should land on afterward — your PrestaShop order-confirmation page is the natural choice. When they return, show a calm "we're confirming your payment" message rather than a success screen. At the moment of redirect the payment may still be confirming on-chain, and you must not have told them the order is complete before the webhook says so. The order-confirmation page reads the current PrestaShop order state, which is still Awaiting crypto payment until your webhook handler promotes it.
If the customer abandons the hosted page or the invoice expires before they pay, nothing bad happens to your store: the order simply stays in Awaiting crypto payment. You can cancel it on a schedule, or let an invoice.expired webhook move it to a cancelled state. Either way, you never fulfilled against a payment that did not arrive.
Step 4 — Drive the order state from a signed webhook
This is where the order actually gets paid. Stand up a public controller on your shop — a front controller in your module is the standard PrestaShop way — that accepts halfin's webhook POST. The handler's discipline is fixed and non-negotiable: recompute the HMAC signature over the raw, unparsed request body using your signing secret, compare it to the signature header in constant time, and only then parse the JSON and act. An unsigned or mismatched request is not a halfin event; return a 4xx and do nothing. The webhook URL is public, so the signature is the only thing standing between a real payment event and a forged one that marks an order paid for free.
Read the raw body before PrestaShop or PHP reserializes it — php://input is the reliable source — because re-encoding changes whitespace and key order and breaks an otherwise-valid signature. Once verified, branch on the event type. On invoice.paid (and invoice.overpaid, which is also a settled outcome with a surplus to flag), look up the PrestaShop order by the id you stored at creation and move it to Payment accepted, which triggers PrestaShop's normal post-payment flow — stock decrement, invoice generation, the confirmation email. On invoice.underpaid, hold the order and surface the shortfall for a human. On invoice.expired, cancel the order.
Make the handler idempotent. halfin delivers at least once, so the same invoice.paid can legitimately arrive twice; key your state change on the stable event id (or simply no-op if the order is already in Payment accepted) so you never decrement stock or email the customer twice. Acknowledge fast with a 200 once you have durably recorded the event, and let any slow work happen afterward — a handler that times out reads as a failed delivery and gets retried.
| Webhook event | What it means | PrestaShop action |
|---|---|---|
| invoice.confirming | Payment seen on-chain, confirming toward the threshold. | Leave the order in Awaiting crypto payment; optionally note it. |
| invoice.paid | Full amount confirmed under the chain's threshold. | Move to Payment accepted (idempotently) — triggers stock, invoice, email. |
| invoice.overpaid | Settled with a surplus over the amount due. | Move to Payment accepted and flag the surplus for refund or credit. |
| invoice.underpaid | A real but insufficient payment arrived. | Hold the order; surface the shortfall for a top-up or partial decision. |
| invoice.expired | The payment window closed before full payment. | Cancel the order; the customer can start a fresh invoice. |
Step 5 — Test the full path before you go live
Exercise the loop end to end against the sandbox before any real customer sees it. Place a test order, confirm the crypto option, and check that your module created an invoice and redirected to hosted checkout. Pay the sandbox invoice, then watch your webhook controller receive invoice.confirming and invoice.paid and promote the order to Payment accepted. The success criterion is that the order moves states from the webhook alone — kill the redirect entirely and the order should still get paid, because fulfilment never depended on the browser coming back.
Prove the signature check actually rejects. Replay a captured event with one byte of the body flipped and confirm your handler returns a 4xx and changes nothing. Then replay a valid event twice and confirm the order ends up paid exactly once, with one confirmation email and one stock decrement — that is your idempotency working. Finally, let an invoice expire without paying it and confirm the order does not get stuck in a way that confuses your fulfilment team.
Watch the operational signals once you are live. Log the event id and the verification result for every webhook, so when an order looks wrong you can point to the exact event and whether it was received, verified, and acted on. If your endpoint starts returning non-2xx, halfin will retry and redeliveries will pile up — which your idempotent handler tolerates, but which is a signal something on your side is failing.
- Confirm the order reaches Payment accepted from the webhook alone, with the redirect disabled.
- Flip a byte in a replayed body and confirm your handler returns 4xx and does nothing.
- Deliver the same paid event twice and confirm exactly one fulfilment, one email, one stock change.
- Let an invoice expire and confirm the order lands in a clean cancelled state, not limbo.