Guide

How to handle failed crypto payouts

A payout you released did not arrive. The address was wrong, the balance was short, the network rejected it — whatever the reason, you now have a payee waiting and a record in your books that says 'sent' when nothing settled. halfin tells you this happened with a payout.failed webhook, and the fix is a tight loop: verify the event, read why it failed, correct the underlying cause, then resubmit the same line with its original idempotency_key so the retry can never become a second payment. This guide walks that loop end to end.

01

What 'failed' means, and what it does not

First, get the state right, because it changes how you react. A payout in halfin moves from created, through pending approval, to released, and only then toward a terminal state on-chain. 'Failed' is one of two terminal outcomes: payout.completed means it settled to the depth the chain requires; payout.failed means it reached a definitive dead end and no funds left your balance for that line. A failed payout is not money in limbo — it is a closed line you are free to re-issue.

That distinction matters because the natural panic reaction — 'did the money go out or not?' — has a clean answer here. A failed payout did not pay the recipient. You are not at risk of having sent funds you also need to resend; you are at risk of the opposite, of marking a payout done when it never settled. So the job is not to claw anything back. The job is to notice the failure reliably, understand it, and re-run the line correctly.

Crucially, a failure is an operational outcome, not an exception you swallow. Most failures have a concrete, fixable cause on your side or the recipient's. Treat payout.failed the same way you treat payout.completed — a signed event that updates your ledger — and the rest of this guide is mechanical.

  • Failed is terminal: the line did not pay the recipient and no funds left your balance for it.
  • The risk is marking a payout 'sent' when it never settled — not double-spending.
  • The fix is to re-issue the line, not to recover or reverse anything.
  • Drive your reaction from the signed payout.failed event, never from a guess or a timeout.
02

Step 1 — Catch payout.failed on a verified webhook

Reconciliation runs on webhooks, not on a polling loop that asks the API 'done yet?'. Two events close a payout: payout.completed when it settles, and payout.failed when it cannot. Subscribe to both. The moment halfin records a terminal state it POSTs the event to your endpoint, and that POST — once verified — is your single source of truth for whether a payout paid.

Verify before you act, every time. Your webhook URL is public the instant you register it, so anyone can POST a forged payout.failed to it. Recompute the HMAC over the exact raw request bytes with your endpoint's signing secret, compare it to the signature header with a constant-time comparison, and only then parse the body. An unsigned or mismatched request is not a halfin event — return a 4xx and do nothing. Acting on a forged failure could flip a genuinely completed payout to 'failed' in your books and trigger a duplicate retry.

Keep the handler idempotent. At-least-once delivery means the same payout.failed can arrive more than once, carrying the same stable event id; dedupe on that id so a redelivery does not mark the same payout failed twice or kick off a second retry. Acknowledge with a 2xx as soon as you have verified and durably recorded the event, then do the slower triage and re-issue work on a background queue.

import { createHmac, timingSafeEqual } from "node:crypto";
import express from "express";

const app = express();
const SIGNING_SECRET = process.env.HALFIN_WEBHOOK_SECRET!;

// Raw body — the HMAC must be computed over the exact bytes received.
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");
    }

    const event = JSON.parse(req.body.toString("utf8"));
    if (event.type === "payout.failed") {
      // Dedupe on event.id, mark the payout failed in your ledger,
      // and queue it for triage + re-issue (see steps below).
      enqueueFailedPayout(event.data); // your own background job
    }

    // Acknowledge fast; do triage and retry off the request path.
    return res.status(200).send("ok");
  },
);
03

Step 2 — Read why it failed before you retry

Do not blind-retry. A payout that failed because the destination address was malformed will fail identically on every resubmission until you fix the address — re-running it unchanged just burns attempts and keeps the payee waiting. The payout.failed event tells you enough to classify the cause, and the cause decides whether you fix data, top up a balance, or escalate to a human.

Most failures fall into a handful of buckets. An invalid or unreceivable destination — a typo'd address, or a USDT line aimed at a network that address cannot receive — needs the destination or currency corrected. An insufficient balance at release means your available balance for that asset could not cover the line; you top up or convert and re-run. A dust-threshold amount, below the minimum a chain will move, needs the amount raised or the payout consolidated. Use the table to route each cause to its fix; the column that matters most is the last one — whether the original idempotency_key is still safe to reuse.

Record the reason against the payout in your own system as you triage. When finance or support asks why a contractor was not paid, you want to point at the exact failed event and the cause, not reconstruct it from memory. That record is also what lets you re-run a batch later and tell, per line, which failures you have already resolved.

Failure causeWhat it meansFixReuse original key?
Invalid / unreceivable destinationThe address is malformed or cannot receive the chosen asset/network.Correct the destination or switch to a network the address supports.Yes — same logical payment, so reuse the original key.
Insufficient balance at releaseYour available balance for that asset could not cover the line.Top up or convert into the asset, then re-run the line.Yes — the payment is unchanged; reuse the original key.
Dust-threshold amountThe amount is below the minimum the chain will move.Raise the amount or consolidate with another payment to that payee.Reuse if the amount is unchanged; a changed amount is a new payment — new key.
Genuinely different paymentYou decide to pay a different amount or a different recipient instead.Issue it as a fresh payout.No — mint a new key; reusing the old one would return the dead line.
04

Step 3 — Retry with the original idempotency_key

Once the cause is fixed, re-issue the line — and this is the step that keeps you from double-paying. Resubmit the corrected payout with the same idempotency_key the original line carried. Because that line failed and never settled, halfin creates the payout this time; the key is the contract that says 'this is the same logical payment as before, not a new one.' If, due to a race or a redelivered event, the line had actually been re-created already, the same key returns the existing payout instead of making a second — so the retry is safe even if you are not certain of the prior state.

The rule has a sharp edge: reuse the key only when it is genuinely the same payment. Correcting a typo'd address or topping up a balance does not change what you are paying or to whom in intent, so the key stays. But if you decide to pay a different amount, or pay a different recipient, that is a new payment and it needs a new key — reusing the old one there would just return the dead failed line and never pay anyone. Treat the key as the answer to 'is this the same disbursement I tried before?'

The retried payout re-enters the same path as any other: it is created, then released for approval before funds move. The example below re-runs one failed line; the same shape resubmits a corrected line inside a larger run, which is exactly how a mass payout recovers — fix the rejected rows and re-run the file, where the lines that already completed are no-ops on their keys and only the corrected ones execute.

# Re-issue a failed payout. Same idempotency_key as the original line:
# the failed line never settled, so this creates the payout once — and a
# stray duplicate request returns the existing one instead of paying twice.
curl -sS -X POST https://api.thehalfin.com/api/v1/payouts \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $HALFIN_PAYOUTS_API_KEY" \
  -d '{
    "currency": "USDT_TRC20",
    "amount": "125.50",
    "destination": "TXn...corrected-address...9aF",
    "idempotency_key": "acct_4821:payout-2026-06"
  }'

# A genuinely different payment (new amount or new recipient) needs a NEW
# key — reusing this one would just return the dead failed line.
# See docs.thehalfin.com for the full payout request and response schema.
05

Step 4 — Close the loop and keep the books honest

A retry is not finished when you POST it; it is finished when the next terminal event arrives. The resubmitted payout is staged, released, broadcast, and then settles — and you learn its real outcome from another signed webhook: payout.completed if it settled this time, or payout.failed again if it hit a fresh dead end. Reconcile the retry the same way you reconciled the original. Until that completed event lands and verifies, the payee's status in your books is 'retrying', not 'paid'.

Avoid the trap of an unbounded retry. If a line fails, you fix it, and it fails again for the same reason, retrying a third time changes nothing — flag it for a human. Some failures are not data you can correct in a loop (a recipient gave you an address on a chain you do not support, a balance cannot be funded in time), and the right move is to surface it to an operator, not to keep hammering the API. A small retry ceiling plus a clear 'needs attention' queue beats a job that silently spins forever.

Finally, mind notifications. If you email a payee 'your payout is on the way' off the released event, a subsequent payout.failed means that promise is now false — reconcile the failure into whatever you told them, and only send a 'paid' confirmation off a verified payout.completed. Driving every payee-facing message from verified terminal events, deduped on the event id, is what keeps your communications consistent with what actually happened on-chain.

  • A retry's true outcome comes from the next verified payout.completed or payout.failed — not from the POST.
  • Cap retries: after a repeat failure for the same reason, route the line to a human, don't loop.
  • Hold the payee status at 'retrying' until a verified completed event arrives.
  • Send 'paid' notifications only off a verified payout.completed, deduped on the event id.