> ## Documentation Index
> Fetch the complete documentation index at: https://doc.gopay.et/llms.txt
> Use this file to discover all available pages before exploring further.

# B2C Callbacks

> Business-to-Customer payout notifications

# B2C (Business-to-Customer) Callbacks

Receive a signed notification when a payout reaches its final state. The callback is sent to the `notify_url` you supplied in the initiate-transfer request.

## Payload Format

```json theme={null}
{
  "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`)                                      |

<Note>
  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.
</Note>

## Verify, Then Process

Always verify the Ed25519 signature before trusting the payload — see [Signature Verification](/webhooks/index#signature-verification) for complete verification code in Node.js, Python, and Go. The examples below assume `verifyCallback` / `verify_callback` from that page.

<Tabs>
  <Tab title="Node.js">
    ```javascript theme={null}
    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;
        }
    });
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    @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
    ```
  </Tab>

  <Tab title="Go">
    ```go theme={null}
    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)
    }
    ```
  </Tab>
</Tabs>

## Sample Payloads

### Successful Payout

```json theme={null}
{
  "txnId": "b2c_tx_20260812000001",
  "status": "completed",
  "amount": 1500.00,
  "txnType": "b2c",
  "receiverPhoneNumber": "+251911223344",
  "reason": "July salary",
  "currency": "ETB"
}
```

### Failed Payout

```json theme={null}
{
  "txnId": "b2c_tx_20260812000002",
  "status": "failed",
  "amount": 750.00,
  "txnType": "b2c",
  "receiverPhoneNumber": "+251922334455",
  "reason": "Vendor settlement",
  "currency": "ETB"
}
```

## Best Practices

<Warning>
  **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 `completed` callback confirms the recipient received funds
</Warning>

<Tip>
  **Reconciliation**

  * Store the `txn_id` from every initiate-transfer response and match callbacks against it
  * Use your `client_reference` as 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)
</Tip>
