Webhooks
Receive real-time notifications when payments, payouts, and refunds occur.
Webhooks allow Chapa to notify your system in real time when important events occur, such as:
- Payment success or failure
- Payment cancellation or timeout
- Refunds (partial or full)
- Payout status updates
Instead of polling the API, Chapa pushes event data to your server as soon as the transaction state changes.
Webhooks are the most reliable way to track payments, payouts, and refunds.
Chapa sends webhooks to notify merchants about payments, payouts, and refunds. All webhook payloads follow a consistent base structure, with additional fields depending on:
webhook_type(payment, payout, refund)event(e.g., payment.success, payout.failed)status(e.g., success, failed, blocked)- The specific business flow (hosted, direct charge, bulk payout, etc.)
Use webhooks for asynchronous updates, but process them idempotently and safely.
Common Payload Structure (All Webhooks)
These fields appear in all webhook payloads, regardless of webhook type.
| Field | Type | Description |
|---|---|---|
webhook_type | string | Category: payment, payout, refund |
event | string | Event name (e.g., payment.success) |
status | string | Current state of the transaction |
mode | string | live or test |
currency | string | ISO currency code (USD, UGX, ETB, DJF) |
amount | string | Amount involved in the event |
merchant_reference | string | Merchant-generated reference |
chapa_reference | string | Chapa-generated reference |
created_at | string | ISO8601 creation timestamp |
updated_at | string | ISO8601 update timestamp |
meta | object | Merchant-defined metadata |
Verify Webhook Origin
For v2 payment, payout, and refund webhooks that use Signature authentication, Chapa sends the signature in the x-chapa-signature header. Set and keep the corresponding webhook secret private when configuring the webhook.
The header is a lowercase hexadecimal HMAC-SHA256 digest calculated as:
HMAC_SHA256(key = webhook_secret, message = raw HTTP request body)The signature covers the exact JSON bytes sent in the request body. Verify it before parsing or reserializing the payload, because JSON.stringify(req.body) can produce a different byte sequence. Use a constant-time comparison.
import crypto from "node:crypto";
import express from "express";
const app = express();
function isValidChapaWebhook(rawBody, receivedSignature) {
const secret = process.env.CHAPA_WEBHOOK_SECRET;
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
return (
typeof receivedSignature === "string" &&
/^[a-f0-9]{64}$/i.test(receivedSignature) &&
crypto.timingSafeEqual(
Buffer.from(receivedSignature, "hex"),
Buffer.from(expectedSignature, "hex")
)
);
}
app.post("/webhooks/chapa", express.raw({ type: "application/json" }), (req, res) => {
if (!isValidChapaWebhook(req.body, req.get("x-chapa-signature"))) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString("utf8"));
// Process event idempotently.
return res.sendStatus(200);
});<?php
$secret = getenv('CHAPA_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$receivedSignature = $_SERVER['HTTP_X_CHAPA_SIGNATURE'] ?? '';
$expectedSignature = hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expectedSignature, $receivedSignature)) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true, flags: JSON_THROW_ON_ERROR);
// Process $event idempotently.
http_response_code(200);import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
@app.post("/webhooks/chapa")
def chapa_webhook():
secret = os.environ["CHAPA_WEBHOOK_SECRET"].encode()
raw_body = request.get_data(cache=True)
received_signature = request.headers.get("X-Chapa-Signature", "")
expected_signature = hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected_signature, received_signature):
abort(401)
event = request.get_json()
# Process event idempotently.
return "", 200package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
)
func chapaWebhook(w http.ResponseWriter, r *http.Request) {
rawBody, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(os.Getenv("CHAPA_WEBHOOK_SECRET")))
mac.Write(rawBody)
expectedSignature := mac.Sum(nil)
receivedSignature, err := hex.DecodeString(r.Header.Get("X-Chapa-Signature"))
if err != nil || !hmac.Equal(expectedSignature, receivedSignature) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
// Decode rawBody and process the event idempotently.
w.WriteHeader(http.StatusOK)
}Do not reconstruct the JSON body before verification or compare the signature with normal string equality. Use the unmodified raw request body, a constant-time comparison, and reject requests with a missing or invalid signature.
Payment Webhook Responses
Payment-Specific Fields
| Field | Type | Description |
|---|---|---|
payment_type | string | Use-case (API, Event, Donation, Direct charge, Hosted, etc.) |
payment_method | string | Method used (telebirr, card, bank_transfer, etc.) |
service_fee | string | Fee charged by Chapa (if applicable) |
customer | object | Payer information |
reason | string | Appears on failure/incomplete/blocked |
cancelled_by | string | Appears on cancelled (USER or SYSTEM) |
refunded_amount | string | Appears on refund events |
auth_type | string | Appears on auth-needed |
expires_at | string | Appears on auth-needed |
Customer Object
| Field | Type | Description |
|---|---|---|
first_name | string | Customer first name |
last_name | string | Customer last name |
email | string | Customer email |
phone_number | string | Customer phone (international format) |
1) SUCCESS — payment.success
{
"webhook_type": "payment",
"event": "payment.success",
"status": "success",
"mode": "live",
"payment_type": "Event",
"currency": "ETB",
"amount": "40000",
"service_fee": "1200",
"merchant_reference": "TXN123SUCCESS",
"chapa_reference": "CHREF123",
"payment_method": "telebirr",
"customer": {
"first_name": "Abebe",
"last_name": "Bikila",
"email": "abebebikila@chapa.co",
"phone_number": "+251960724272"
},
"meta": {
"order_id": "ORD-99821"
},
"created_at": "2025-11-07T13:00:00Z",
"updated_at": "2025-11-07T13:00:00Z"
}2) FAILED — payment.failed
Additional field: reason (example: INSUFFICIENT_FUNDS)
{
"webhook_type": "payment",
"event": "payment.failed",
"status": "failed",
"mode": "live",
"payment_type": "API",
"currency": "ETB",
"amount": "40000",
"service_fee": "1200",
"merchant_reference": "TXN123FAILED",
"chapa_reference": "CHREF123",
"reason": "INSUFFICIENT_FUNDS",
"customer": {
"first_name": "Abebe",
"last_name": "Bikila",
"email": "abebebikila@chapa.co",
"phone_number": "+251960724272"
},
"meta": {
"order_id": "ORD-99821"
},
"created_at": "2025-11-07T13:00:00Z",
"updated_at": "2025-11-07T13:00:00Z"
}3) CANCELLED — payment.cancelled
Additional field: cancelled_by (USER or SYSTEM)
{
"webhook_type": "payment",
"event": "payment.cancelled",
"status": "cancelled",
"mode": "live",
"payment_type": "Donation",
"currency": "ETB",
"amount": "40000",
"service_fee": "1200",
"merchant_reference": "TXN123CANCELLED",
"chapa_reference": "CHREF123",
"cancelled_by": "USER",
"customer": {
"first_name": "Abebe",
"last_name": "Bikila",
"email": "abebebikila@chapa.co",
"phone_number": "+251960724272"
},
"meta": {
"order_id": "ORD-99821"
},
"created_at": "2025-11-07T13:00:00Z",
"updated_at": "2025-11-07T13:00:00Z"
}4) INCOMPLETE — payment.incomplete
Additional field: reason (example: TIMEOUT, ABANDONED)
{
"webhook_type": "payment",
"event": "payment.incomplete",
"status": "incomplete",
"mode": "live",
"payment_type": "API",
"currency": "ETB",
"amount": "40000",
"service_fee": "1200",
"merchant_reference": "TXN123INCOMPLETE",
"chapa_reference": "CHREF123",
"reason": "TIMEOUT",
"customer": {
"first_name": "Abebe",
"last_name": "Bikila",
"email": "abebebikila@chapa.co",
"phone_number": "+251960724272"
},
"meta": {
"order_id": "ORD-99821"
},
"created_at": "2025-11-07T13:00:00Z",
"updated_at": "2025-11-07T13:00:00Z"
}5) PARTIALLY_REFUNDED — payment.partially_refunded
Additional field: refunded_amount
{
"webhook_type": "refund",
"event": "payment.partially_refunded",
"status": "partially_refunded",
"mode": "live",
"payment_type": "API",
"currency": "ETB",
"amount": "40000",
"refunded_amount": "15000",
"service_fee": "0",
"merchant_reference": "TXN123PARTIALREFUND",
"chapa_reference": "CHREF123",
"customer": {
"first_name": "Abebe",
"last_name": "Bikila",
"email": "abebebikila@chapa.co",
"phone_number": "+251960724272"
},
"meta": {
"order_id": "ORD-99821"
},
"created_at": "2025-11-07T13:00:00Z",
"updated_at": "2025-11-07T13:00:00Z"
}6) FULLY_REFUNDED — payment.fully_refunded
Additional field: refunded_amount (equals original amount)
{
"webhook_type": "refund",
"event": "payment.fully_refunded",
"status": "fully_refunded",
"mode": "live",
"payment_type": "Event",
"currency": "ETB",
"amount": "40000",
"refunded_amount": "40000",
"service_fee": "0",
"merchant_reference": "TXN123FULLREFUND",
"chapa_reference": "CHREF123",
"customer": {
"first_name": "Abebe",
"last_name": "Bikila",
"email": "abebebikila@chapa.co",
"phone_number": "+251960724272"
},
"meta": {
"order_id": "ORD-99821"
},
"created_at": "2025-11-07T13:00:00Z",
"updated_at": "2025-11-07T13:00:00Z"
}7) AUTH_NEEDED — payment.auth_needed
Additional fields: auth_type, expires_at
{
"webhook_type": "payment",
"event": "payment.auth_needed",
"status": "auth_needed",
"mode": "live",
"payment_type": "Direct charge",
"currency": "ETB",
"amount": "40000",
"service_fee": "1200",
"merchant_reference": "TXN123AUTH",
"chapa_reference": "CHREF123",
"auth_type": "PIN",
"customer": {
"first_name": "Abebe",
"last_name": "Bikila",
"email": "abebebikila@chapa.co",
"phone_number": "+251960724272"
},
"meta": {
"order_id": "ORD-99821"
},
"created_at": "2025-11-07T12:00:00Z",
"updated_at": "2025-11-07T13:00:00Z",
"expires_at": "2025-11-07T13:00:00Z"
}8) BLOCKED — payment.blocked
Additional field: reason (example: COMPLIANCE_REVIEW)
{
"webhook_type": "payment",
"event": "payment.blocked",
"status": "blocked",
"mode": "live",
"payment_type": "API",
"currency": "ETB",
"amount": "40000",
"service_fee": "1200",
"merchant_reference": "TXN123BLOCKED",
"chapa_reference": "CHREF123",
"reason": "COMPLIANCE_REVIEW",
"customer": {
"first_name": "Abebe",
"last_name": "Bikila",
"email": "abebebikila@chapa.co",
"phone_number": "+251960724272"
},
"meta": {
"order_id": "ORD-99821"
},
"created_at": "2025-11-07T13:00:00Z",
"updated_at": "2025-11-07T13:00:00Z"
}Payout Webhook Responses
Payout-Specific Fields
| Field | Type | Description |
|---|---|---|
payout_type | string | customer_payout, merchant_payout, bulk_payout |
service_fee | string | Fee charged for processing |
processor_reference | string | Reference from bank/provider |
account | object | Destination account info |
reason | string | Present on failures/blocks/reversals |
auth_type | string | Present on auth-needed |
otp_channel | array | Present on otp-needed |
otp_attempts | number | Present on otp-failed |
Account Object
| Field | Type | Description |
|---|---|---|
account_name | string | Name on receiving account |
account_number | string | Destination account number |
bank_slug | string | Provider identifier |
bank_name | string | Provider display name |
1) SUCCESS — payout.success
{
"webhook_type": "payout",
"event": "payout.success",
"status": "success",
"payout_type": "customer_payout",
"currency": "ETB",
"amount": "200000",
"service_fee": "6000",
"merchant_reference": "PAYOUT123SUCCESS",
"chapa_reference": "CHP123SUCCESS",
"processor_reference": "BANKREF123",
"account": {
"account_name": "Abebe Bikila",
"account_number": "123567890987",
"bank_slug": "cbe",
"bank_name": "Commercial Bank of Ethiopia"
},
"meta": {
"order_id": "ORD-99821"
},
"created_at": "2025-11-07T13:00:00Z",
"updated_at": "2025-11-07T13:00:00Z"
}2) FAILED — payout.failed
Additional field: reason (example: BANK_REJECTED)
{
"webhook_type": "payout",
"event": "payout.failed",
"status": "failed",
"payout_type": "customer_payout",
"currency": "ETB",
"amount": "200000",
"service_fee": "6000",
"merchant_reference": "PAYOUT123FAILED",
"chapa_reference": "CHP123FAILED",
"account": {
"account_name": "Abebe Bikila",
"account_number": "123567890987",
"bank_slug": "cbe",
"bank_name": "Commercial Bank of Ethiopia"
},
"reason": "BANK_REJECTED",
"meta": {
"order_id": "ORD-99821"
},
"created_at": "2025-11-07T13:00:00Z",
"updated_at": "2025-11-07T13:00:00Z"
}3) REVERSED — payout.reversed
Additional field: reason (example: BENEFICIARY_ACCOUNT_ISSUE)
{
"webhook_type": "payout",
"event": "payout.reversed",
"status": "reversed",
"payout_type": "customer_payout",
"currency": "ETB",
"amount": "200000",
"service_fee": "6000",
"merchant_reference": "PAYOUT123REVERSED",
"chapa_reference": "CHP123REVERSED",
"processor_reference": "BANKREF123",
"account": {
"account_name": "Abebe Bikila",
"account_number": "123567890987",
"bank_slug": "cbe",
"bank_name": "Commercial Bank of Ethiopia"
},
"reason": "BENEFICIARY_ACCOUNT_ISSUE",
"meta": {
"order_id": "ORD-99821"
},
"created_at": "2025-11-07T13:00:00Z",
"updated_at": "2025-11-07T13:00:00Z"
}4) BLOCKED — payout.blocked
Additional field: reason (example: COMPLIANCE_REVIEW)
{
"webhook_type": "payout",
"event": "payout.blocked",
"status": "blocked",
"payout_type": "customer_payout",
"currency": "ETB",
"amount": "200000",
"service_fee": "6000",
"merchant_reference": "PAYOUT123BLOCKED",
"chapa_reference": "CHP123BLOCKED",
"account": {
"account_name": "Abebe Bikila",
"account_number": "123567890987",
"bank_slug": "cbe",
"bank_name": "Commercial Bank of Ethiopia"
},
"reason": "COMPLIANCE_REVIEW",
"meta": {
"order_id": "ORD-99821"
},
"created_at": "2025-11-07T13:00:00Z",
"updated_at": "2025-11-07T13:00:00Z"
}5) AUTH_NEEDED — payout.auth_needed
Additional field: auth_type
{
"webhook_type": "payout",
"event": "payout.auth_needed",
"status": "auth_needed",
"payout_type": "customer_payout",
"currency": "ETB",
"amount": "200000",
"service_fee": "6000",
"merchant_reference": "PAYOUT123AUTH",
"chapa_reference": "CHP123AUTH",
"auth_type": "PIN",
"account": {
"account_name": "Abebe Bikila",
"account_number": "123567890987",
"bank_slug": "cbe",
"bank_name": "Commercial Bank of Ethiopia"
},
"meta": {
"order_id": "ORD-99821"
},
"created_at": "2025-11-07T13:00:00Z",
"updated_at": "2025-11-07T13:00:00Z"
}6) OTP_NEEDED — payout.otp_needed
Additional field: otp_channel (example: ["sms", "email", "telegram"])
{
"webhook_type": "payout",
"event": "payout.otp_needed",
"status": "otp_needed",
"payout_type": "customer_payout",
"currency": "ETB",
"amount": "200000",
"service_fee": "6000",
"merchant_reference": "PAYOUT123OTP",
"chapa_reference": "CHP123OTP",
"otp_channel": ["sms", "email", "telegram"],
"account": {
"account_name": "Abebe Bikila",
"account_number": "123567890987",
"bank_slug": "cbe",
"bank_name": "Commercial Bank of Ethiopia"
},
"meta": {
"order_id": "ORD-99821"
},
"created_at": "2025-11-07T13:00:00Z",
"updated_at": "2025-11-07T13:00:00Z"
}7) OTP_FAILED — payout.otp_failed
Additional field: otp_attempts
{
"webhook_type": "payout",
"event": "payout.otp_failed",
"status": "otp_failed",
"payout_type": "merchant_payout",
"currency": "ETB",
"amount": "200000",
"service_fee": "6000",
"merchant_reference": "PAYOUT123OTPFAILED",
"chapa_reference": "CHP123OTPFAILED",
"account": {
"account_name": "Abebe Bikila",
"account_number": "123567890987",
"bank_slug": "cbe",
"bank_name": "Commercial Bank of Ethiopia"
},
"otp_attempts": 3,
"meta": {
"order_id": "ORD-99821"
},
"created_at": "2025-11-07T13:00:00Z",
"updated_at": "2025-11-07T13:00:00Z"
}Payment vs Payout vs Refund Payload Differences
| Type | Identifiers | Extra Fields | Typical Events |
|---|---|---|---|
| Payment | merchant_reference, chapa_reference | payment_type, payment_method, customer, service_fee | payment.success, payment.failed, payment.cancelled |
| Payout | merchant_reference, chapa_reference, processor_reference | payout_type, account, otp_channel, auth_type | payout.success, payout.failed, payout.reversed |
| Refund | Sent as payment lifecycle events | refunded_amount | payment.partially_refunded, payment.fully_refunded |
Event Handling Patterns
1) Idempotency (Must Have)
Chapa may deliver the same webhook more than once.
Recommended strategy:
- Create a
webhook_eventstable - Store a deduplication key such as:
event + chapa_reference + status + updated_at - Then:
- If already processed → return
200 OKimmediately - Otherwise → process and store
- If already processed → return
2) Retries
If your endpoint fails (non-200 or timeout), Chapa may retry.
Your endpoint should:
- Return
200 OKquickly - Process heavy logic asynchronously (queue/job) if possible
- Be safe for repeats (idempotent)
3) Ordering
Webhooks can arrive:
- Out of order
- Close together
- With state transitions (
pending → success)
Recommended:
- Update transaction state only if the new state is newer (
updated_at) - Or enforce state progression rules (
pending → success/failed)
4) Verification Strategy
Webhooks are the best real-time signal. Verification is the best confirmation.
Recommended approach:
- Use webhook to update state immediately
- Verify if:
- Order is high-value
- State is unusual (
blocked/auth_needed) - Your fulfillment depends on strict confirmation
Next Steps
- Verify Payments - Confirm payment status server-side
- Accept Payments - Full hosted payment integration
- Quick Start - End-to-end payment flow