B2C (Business-to-Customer) Callbacks
Receive a signed notification when a payout reaches its final state. The callback is sent to thenotify_url you supplied in the initiate-transfer request.
Payload Format
{
"txnId": "b2c_tx_6fcb15a983c54a7e97b1",
"status": "completed",
"amount": 500.00,
"txnType": "b2c",
"receiverPhoneNumber": "+251912345678",
"reason": "Salary payment",
"currency": "ETB"
}
Field Reference
| Field | Type | Description |
|---|---|---|
txnId | string | Transaction identifier returned by initiate-transfer (txn_id) |
status | string | Final status: completed, failed, expired, or cancelled |
amount | number | Payout amount |
txnType | string | Always "b2c" for business-to-customer transactions |
receiverPhoneNumber | string | Recipient’s phone number |
reason | string | Purpose of the payout, as submitted at initiation |
currency | string | Currency code (e.g. ETB) |
A
failed payout means the funds debited at initiation are returned to your merchant wallet. Reconcile against the txnId you stored when initiating the transfer.Verify, Then Process
Always verify the Ed25519 signature before trusting the payload — see Signature Verification for complete verification code in Node.js, Python, and Go. The examples below assumeverifyCallback / verify_callback from that page.
- Node.js
- Python
- Go
app.post('/b2c-webhook', async (req, res) => {
if (!verifyCallback(req)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const { txnId, status, amount, txnType, receiverPhoneNumber, reason, currency } = req.body;
if (txnType !== 'b2c') {
return res.status(400).json({ error: 'Unexpected transaction type' });
}
// Dedupe: retries can deliver the same settlement more than once
if (await alreadyProcessed(req.headers['x-request-id'])) {
return res.status(200).json({ message: 'Already processed' });
}
// Acknowledge fast, process async
res.status(200).json({ message: 'OK' });
switch (status) {
case 'completed':
await updatePayoutStatus(txnId, 'completed');
await notifyRecipientSuccess(receiverPhoneNumber, amount, currency);
break;
case 'failed':
case 'expired':
case 'cancelled':
// Funds are returned to your wallet on failure
await updatePayoutStatus(txnId, status);
await flagForReview(txnId, reason);
break;
}
});
@app.route("/b2c-webhook", methods=["POST"])
def b2c_webhook():
if not verify_callback(request):
abort(401)
data = request.get_json()
if data.get("txnType") != "b2c":
return {"error": "Unexpected transaction type"}, 400
# Dedupe: retries can deliver the same settlement more than once
if already_processed(request.headers["X-Request-ID"]):
return {"message": "Already processed"}, 200
txn_id = data["txnId"]
status = data["status"]
if status == "completed":
update_payout_status(txn_id, "completed")
notify_recipient_success(
data["receiverPhoneNumber"], data["amount"], data.get("currency", "ETB")
)
elif status in ("failed", "expired", "cancelled"):
# Funds are returned to your wallet on failure
update_payout_status(txn_id, status)
flag_for_review(txn_id, data.get("reason", ""))
return {"message": "OK"}, 200
type b2cCallback struct {
TxnID string `json:"txnId"`
Status string `json:"status"`
Amount float64 `json:"amount"`
TxnType string `json:"txnType"`
ReceiverPhoneNumber string `json:"receiverPhoneNumber"`
Reason string `json:"reason"`
Currency string `json:"currency"`
}
func b2cWebhookHandler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !verifyCallback(r, body) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var cb b2cCallback
if err := json.Unmarshal(body, &cb); err != nil || cb.TxnType != "b2c" {
http.Error(w, "unexpected payload", http.StatusBadRequest)
return
}
// Dedupe: retries can deliver the same settlement more than once
if alreadyProcessed(r.Header.Get("X-Request-ID")) {
w.WriteHeader(http.StatusOK)
return
}
// Acknowledge fast, process async
w.WriteHeader(http.StatusOK)
go processB2CSettlement(cb)
}
Sample Payloads
Successful Payout
{
"txnId": "b2c_tx_20260812000001",
"status": "completed",
"amount": 1500.00,
"txnType": "b2c",
"receiverPhoneNumber": "+251911223344",
"reason": "July salary",
"currency": "ETB"
}
Failed Payout
{
"txnId": "b2c_tx_20260812000002",
"status": "failed",
"amount": 750.00,
"txnType": "b2c",
"receiverPhoneNumber": "+251922334455",
"reason": "Vendor settlement",
"currency": "ETB"
}
Best Practices
Security
- Always verify the Ed25519 signature against the raw body bytes before parsing JSON
- Enforce a timestamp freshness window and dedupe on
X-Request-ID - Treat the initiate-transfer 200 response as “accepted”, never as “paid” — only the
completedcallback confirms the recipient received funds
Reconciliation
- Store the
txn_idfrom every initiate-transfer response and match callbacks against it - Use your
client_referenceas the join key between your systems and GoPay’s - Alert on transfers that have neither a callback nor a resolution after the full retry window (roughly 45 minutes)
