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.
POST /v1/transactions/{reference}/reconcileScope: 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:
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
| Field | Type | Required | Description |
|---|---|---|---|
transaction_id | string | Yes | Transaction ID from the customer’s MoMo confirmation (MTN: 73012849466, Telecel: 0000011528209461, AT: PP260203.1254.C00772) |
amount | decimal | No | Strict-check opt-in. If supplied, mismatch returns AMOUNT_MISMATCH. Omit to accept whatever was paid. |
notes | string | No | Free-text context for the audit log |
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.
{
"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
| Field | Description |
|---|---|
new_transaction_status | SUCCESS, PARTIAL, or OVERPAID |
matched_amount | Actual amount received |
expected_amount | Amount on the original payment request |
difference | Signed: matched - expected. Negative = underpaid. |
difference_type | exact, 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
{
"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"
}| HTTP | error.code | Meaning |
|---|---|---|
409 | TRANSACTION_ID_ALREADY_USED | This ID already verified a different payment. The payer is re-using one receipt across orders. Do not fulfil. |
409 | ALREADY_VERIFIED | This order is already paid. Includes transaction_status. Safe to treat as paid, but do not fulfil twice. |
404 | NO_MATCHING_TRANSACTION | No payment on your account carries that ID. Either it’s wrong, or the payment SMS hasn’t synced yet. |
422 | AMOUNT_MISMATCH | Only 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.