Payments

Manual reconciliation

Verify a payment by transaction ID when auto-match missed it.

Use this when a customer claims to have paid but their payment didn’t auto-verify. Usually they didn’t include the HPN code, or paid from an unexpected number. Ask them for the transaction ID from their MoMo confirmation, then submit it here.

Bash
POST /v1/transactions/{reference}/reconcile

Scope: transactions:write

Only fulfil on a 2xx

The transaction ID comes from your customer, so treat it as untrusted input. A 2xx from this endpoint is the only response that means a payment was matched to this order. Every refusal is a non-2xx with "success": false and an error.code.

Gate fulfilment on the status code:

JS
const res = await fetch(url, { method: 'POST', headers, body });

if (!res.ok) {
  const { error } = await res.json();
  // error.code tells you why. Do not fulfil.
  return showCustomer(error.message);
}

fulfilOrder();

Do not gate on the presence of a response body, and do not retry a refusal with the same ID. If you re-submit an ID we already consumed, you will keep getting 409.

Request

FieldTypeRequiredDescription
transaction_idstringYesTransaction ID from the customer’s MoMo confirmation (MTN: 73012849466, Telecel: 0000011528209461, AT: PP260203.1254.C00772)
amountdecimalNoStrict-check opt-in. If supplied, mismatch returns AMOUNT_MISMATCH. Omit to accept whatever was paid.
notesstringNoFree-text context for the audit log
Bash
curl -X POST https://api.harpoonsms.com/v1/transactions/hpn_trx_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6/reconcile 
  -H "Authorization: Bearer hpn_live_sk_xxxxxxxxxxxxx" 
  -H "Content-Type: application/json" 
  -d '{ "transaction_id": "73012849466" }'

Response

200 OK. Money was matched to this order.

JSON
{
  "success": true,
  "data": {
    "reconciliation_id": "rec_b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7",
    "status": "VERIFIED",
    "transaction_id": "73012849466",
    "transaction_updated": true,
    "new_transaction_status": "SUCCESS",
    "matched_amount": "150.00",
    "expected_amount": "150.00",
    "difference": "0.00",
    "difference_type": "exact",
    "message": "Payment verified successfully"
  }
}

data.status is always VERIFIED on a 2xx. Read new_transaction_status to tell an exact payment from an under or overpayment.

Response fields

FieldDescription
new_transaction_statusSUCCESS, PARTIAL, or OVERPAID
matched_amountActual amount received
expected_amountAmount on the original payment request
differenceSigned: matched - expected. Negative = underpaid.
difference_typeexact, underpaid, or overpaid

A successful reconcile fires a transaction.completed webhook.

PARTIAL means the payment arrived but was short. It is a 2xx because money did move. If you only accept payment in full, check new_transaction_status before you fulfil, or send amount to make a short payment a hard 422.

Refusals

JSON
{
  "success": false,
  "error": {
    "code": "TRANSACTION_ID_ALREADY_USED",
    "message": "Transaction 73012849466 has already been used to verify a different payment. Do not fulfil this order."
  },
  "expected_amount": "150.00"
}
HTTPerror.codeMeaning
409TRANSACTION_ID_ALREADY_USEDThis ID already verified a different payment. The payer is re-using one receipt across orders. Do not fulfil.
409ALREADY_VERIFIEDThis order is already paid. Includes transaction_status. Safe to treat as paid, but do not fulfil twice.
404NO_MATCHING_TRANSACTIONNo payment on your account carries that ID. Either it’s wrong, or the payment SMS hasn’t synced yet.
422AMOUNT_MISMATCHOnly when you sent amount. Includes claimed_amount and matched_amount.

TRANSACTION_ID_ALREADY_USED is a fraud signal, not a retry. It means someone quoted a receipt that we already matched to another order. Log these on your side: repeated refusals from one customer are worth acting on.