Call any endpoint with your API keys — no Postman required. Open API playground

Webhooks Guide

Set up webhooks to receive real-time payment notifications

API Base URL

All webhook API requests should be made to the following base URL:

https://demo-wallet.wearemarz.com/api/v1

Overview

Callbacks are HTTP POST requests sent to your application when payment events occur, such as payment completion or failure. This allows for real-time updates without polling the API.

How to Receive Callbacks

To receive callbacks, simply include a callback_url parameter in any API request that supports callbacks (such as collection requests).

Example Request

POST /api/v1/collections
{
  "amount": 10000,
  "phone_number": "+256712345678",
  "callback_url": "https://your-domain.com/webhook/callback"
}

Important Notes

  • Include callback_url in your request to receive callbacks
  • Your callback endpoint must accept POST requests
  • Your callback endpoint should return HTTP 200 to acknowledge receipt
  • Callbacks are sent asynchronously after transaction processing is complete

Webhook Signing (Optional)

By default, callbacks are sent as plain JSON POST requests — no signature headers. You can optionally enable webhook signing to verify that requests genuinely came from MarzPay.

Your signing secret

MarzPay generates your signing secret in the dashboard when you enable signing.

  1. Open Business Settings → Webhooks & Security.
  2. Turn on Sign outgoing webhooks.
  3. Click Reveal, then Copy.

Signature headers

When signing is enabled, every outgoing callback (direct callback_url and registered webhooks) includes these headers:

  • X-MarzPay-Timestamp — Unix timestamp (seconds)
  • X-MarzPay-Signature — Format: t={timestamp},v1={hex_signature}

Verification steps

  1. Read the raw request body exactly as received (the JSON string, before parsing).
  2. Build a string: {timestamp}.{raw_body} — use the value from X-MarzPay-Timestamp.
  3. Hash that string with HMAC-SHA256 and your signing secret. You get a hex string like a3f8b2c1….
  4. Open the X-MarzPay-Signature header. It looks like: t=1712345678,v1=a3f8b2c1d4e5f6… The part after v1= is MarzPay’s hash. If it exactly matches the hash you computed in step 3, the callback is genuine. If it does not match, reject the request.

In code, use a constant-time compare (e.g. PHP hash_equals()) rather than ==.

Code examples

PHP
$signingSecret = 'your_signing_secret';

$timestamp = $_SERVER['HTTP_X_MARZPAY_TIMESTAMP'] ?? '';
$signatureHeader = $_SERVER['HTTP_X_MARZPAY_SIGNATURE'] ?? '';
$rawBody = file_get_contents('php://input');

$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $signingSecret);

preg_match('/v1=([a-f0-9]+)/', $signatureHeader, $matches);
$received = $matches[1] ?? '';

if (!hash_equals($expected, $received)) {
    http_response_code(401);
    exit('Invalid signature');
}
Node.js
const crypto = require('crypto');

const signingSecret = 'your_signing_secret';
const timestamp = req.headers['x-marzpay-timestamp'] || '';
const signatureHeader = req.headers['x-marzpay-signature'] || '';
const rawBody = req.rawBody; // raw request body string (before JSON.parse)

const expected = crypto
  .createHmac('sha256', signingSecret)
  .update(`${timestamp}.${rawBody}`)
  .digest('hex');

const match = signatureHeader.match(/v1=([a-f0-9]+)/);
const received = match ? match[1] : '';

const valid = received.length === expected.length
  && crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));

if (!valid) {
  return res.status(401).send('Invalid signature');
}
Python
import hmac
import hashlib
import re

signing_secret = 'your_signing_secret'
timestamp = request.headers.get('X-MarzPay-Timestamp', '')
signature_header = request.headers.get('X-MarzPay-Signature', '')
raw_body = request.get_data(as_text=True)

expected = hmac.new(
    signing_secret.encode('utf-8'),
    f'{timestamp}.{raw_body}'.encode('utf-8'),
    hashlib.sha256,
).hexdigest()

match = re.search(r'v1=([a-f0-9]+)', signature_header)
received = match.group(1) if match else ''

if not hmac.compare_digest(expected, received):
    return 'Invalid signature', 401
Java
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

String signingSecret = "your_signing_secret";
String timestamp = request.getHeader("X-MarzPay-Timestamp");
String signatureHeader = request.getHeader("X-MarzPay-Signature");
String rawBody = requestBody; // raw request body string

Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(signingSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] hash = mac.doFinal((timestamp + "." + rawBody).getBytes(StandardCharsets.UTF_8));

StringBuilder expected = new StringBuilder();
for (byte b : hash) {
    expected.append(String.format("%02x", b));
}

Matcher match = Pattern.compile("v1=([a-f0-9]+)").matcher(signatureHeader);
String received = match.find() ? match.group(1) : "";

if (!expected.toString().equals(received)) {
    response.setStatus(401);
    return;
}

Callback Flow

Understanding how callbacks work in our system and when you'll receive notifications.

How it works

  1. You create a collection request with a callback_url
  2. The customer completes the payment
  3. MarzPay updates the transaction status (completed, failed, or cancelled)
  4. Your balance is updated if the payment succeeded
  5. MarzPay sends a callback to your callback_url (HTTP POST)

This applies to all payment methods — mobile money, card, and others. The callback payload structure is the same; only the payment details in the payload differ.

When Callbacks Are Sent

Callbacks are sent for ALL final transaction statuses:

  • Completed: Payment successful, balance updated
  • Failed: Payment failed, no balance change
  • Cancelled: Payment cancelled, no balance change
  • All internal processing complete

Callbacks are NOT sent for: pending, processing, or any intermediate statuses.

Callback Payload Structure

The structure of the data sent to your callback URL. The event_type and transaction status will vary based on the transaction outcome.

Optional metadata on every callback

If you sent metadata on POST /collect-money or POST /send-money, the same array is included as a top-level metadata field on:

  • Direct callback_url POSTs
  • Dashboard webhooks
  • Sandbox callbacks
  • Both success and failure final statuses (*.completed / *.failed)

If you omitted metadata on create, the key is not present on the callback.

"metadata": [
  { "orderId": "ORD-123456789" },
  { "customerId": "customer@email.com", "isPII": true }
]

Do not confuse this with data.metadata on some API create responses (response diagnostics only).

Collection Object Fields

  • provider_transaction_id: A unified field containing the transaction ID from the payment provider (MTN, Airtel, or M-Pesa), e.g. "148769164724". Included on the collection object when the provider returns an ID. Use this field in callbacks — not provider_reference.
  • provider_reference: Not sent in collection callbacks. It may appear as null on the immediate create-collection API response only; do not model collection webhooks around it.
  • provider: The payment provider used (mtn, airtel, or mpesa)
  • phone_number: The phone number used for the transaction
  • amount: The transaction amount with formatted and raw values
  • mode: The transaction mode (e.g., mtnuganda, airteluganda, live, sandbox)
  • Top-level metadata (optional): Echo of request metadata from collect-money — see box above.

Successful Payment Payload (MTN Example)

{
  "event_type": "collection.completed",
  "transaction": {
    "uuid": "transaction-uuid",
    "reference": "transaction-reference",
    "status": "completed",
    "amount": {
      "formatted": "10,000.00",
      "raw": 10000,
      "currency": "UGX"
    },
    "provider": "mtn",
    "phone_number": "+256712345678",
    "description": "Payment description",
    "created_at": "2025-08-20T15:18:48.000000Z",
    "updated_at": "2025-08-20T15:18:48.000000Z"
  },
  "collection": {
    "provider": "mtn",
    "phone_number": "+256712345678",
    "amount": {
      "formatted": "10,000.00",
      "raw": 10000,
      "currency": "UGX"
    },
    "mode": "mtnuganda",
    "provider_transaction_id": "148769164724"
  },
  "metadata": [
    { "orderId": "ORD-123456789" },
    { "customerId": "customer@email.com", "isPII": true }
  ]
}

Successful Payment Payload (Airtel Example)

{
  "event_type": "collection.completed",
  "transaction": {
    "uuid": "transaction-uuid",
    "reference": "transaction-reference",
    "status": "completed",
    "amount": {
      "formatted": "10,000.00",
      "raw": 10000,
      "currency": "UGX"
    },
    "provider": "airtel",
    "phone_number": "+256712345678",
    "description": "Payment description",
    "created_at": "2025-08-20T15:18:48.000000Z",
    "updated_at": "2025-08-20T15:18:48.000000Z"
  },
  "collection": {
    "provider": "airtel",
    "phone_number": "+256712345678",
    "amount": {
      "formatted": "10,000.00",
      "raw": 10000,
      "currency": "UGX"
    },
    "mode": "airteluganda",
    "provider_transaction_id": "AIRTEL_MONEY_ID"
  },
  "metadata": [
    { "orderId": "ORD-123456789" },
    { "customerId": "customer@email.com", "isPII": true }
  ]
}

Successful Payment Payload (M-Pesa / Kenya Example)

{
  "event_type": "collection.completed",
  "transaction": {
    "uuid": "transaction-uuid",
    "reference": "transaction-reference",
    "status": "completed",
    "amount": {
      "formatted": "100.00",
      "raw": 100,
      "currency": "KES"
    },
    "provider": "mpesa",
    "phone_number": "+254710000000",
    "description": "Payment description",
    "created_at": "2026-08-01T14:30:00.000000Z",
    "updated_at": "2026-08-01T14:31:00.000000Z"
  },
  "collection": {
    "provider": "mpesa",
    "phone_number": "+254710000000",
    "amount": {
      "formatted": "100.00",
      "raw": 100,
      "currency": "KES"
    },
    "mode": "live",
    "provider_transaction_id": "ABC123"
  },
  "metadata": [
    { "orderId": "ORD-123456789" },
    { "customerId": "customer@email.com", "isPII": true }
  ]
}

Kenya collect & send overview: Kenya (M-Pesa) guide.

Failed Payment Payload

{
  "event_type": "collection.failed",
  "transaction": {
    "uuid": "transaction-uuid",
    "reference": "transaction-reference",
    "status": "failed",
    "amount": {
      "formatted": "10,000.00",
      "raw": 10000,
      "currency": "UGX"
    },
    "provider": "mtn",
    "phone_number": "+256712345678",
    "description": "Payment description",
    "created_at": "2025-08-20T15:18:48.000000Z",
    "updated_at": "2025-08-20T15:18:48.000000Z"
  },
  "collection": {
    "provider": "mtn",
    "phone_number": "+256712345678",
    "amount": {
      "formatted": "10,000.00",
      "raw": 10000,
      "currency": "UGX"
    },
    "mode": "mtnuganda",
    "provider_transaction_id": "MTN_FINANCIAL_TRANSACTION_ID"
  },
  "metadata": [
    { "orderId": "ORD-123456789" },
    { "customerId": "customer@email.com", "isPII": true }
  ]
}

Failed callbacks also include top-level metadata when you sent it on create.

Card payments callback

Card collections use the same callback format as mobile money. When you provide a callback_url on a card collection, we send an HTTP POST to that URL with a JSON body once the card payment is completed or failed (after the customer returns from the card gateway). The payload structure is identical to the collection examples above; only the provider and some optional fields differ.

Card-specific details

  • provider is "card payments" (lowercase)
  • phone_number may be null (card collections do not require a phone number)
  • provider_transaction_id is the gateway transaction ID from the card provider when available
  • event_type is collection.completed or collection.failed as for other collections
  • Top-level metadata is included when you sent it on the card collect-money request

Example: Card payment completed

{
  "event_type": "collection.completed",
  "transaction": {
    "uuid": "transaction-uuid",
    "reference": "b59d3d6d-5827-41ee-b455-18dd20ef1c8a",
    "status": "completed",
    "amount": {
      "formatted": "5,000.00",
      "raw": 5000,
      "currency": "UGX"
    },
    "provider": "card payments",
    "phone_number": null,
    "description": "Order payment",
    "created_at": "2025-08-20T15:18:48.000000Z",
    "updated_at": "2025-08-20T15:19:02.000000Z"
  },
  "collection": {
    "provider": "card payments",
    "phone_number": null,
    "amount": {
      "formatted": "5,000.00",
      "raw": 5000,
      "currency": "UGX"
    },
    "mode": "card paymentsuganda",
    "provider_transaction_id": "PROVIDER_TRANSACTION_ID"
  }
}

Example: Card payment failed

{
  "event_type": "collection.failed",
  "transaction": {
    "uuid": "transaction-uuid",
    "reference": "b59d3d6d-5827-41ee-b455-18dd20ef1c8a",
    "status": "failed",
    "amount": { "formatted": "5,000.00", "raw": 5000, "currency": "UGX" },
    "provider": "card payments",
    "phone_number": null,
    "description": "Order payment",
    "created_at": "2025-08-20T15:18:48.000000Z",
    "updated_at": "2025-08-20T15:19:02.000000Z"
  },
  "collection": {
    "provider": "card payments",
    "phone_number": null,
    "amount": { "formatted": "5,000.00", "raw": 5000, "currency": "UGX" },
    "mode": "card paymentsuganda",
    "provider_transaction_id": null
  }
}

Your callback endpoint should accept POST with Content-Type: application/json and return HTTP 200. Use event_type and transaction.status to determine success or failure; use transaction.reference to match the collection to your order.

Disbursement Object Fields

  • provider_transaction_id: A unified field containing the transaction ID from the payment provider. This single field works for MTN, Airtel, and M-Pesa — it automatically contains the correct provider transaction ID based on which provider processed the disbursement. The field is included in the callback when the provider transaction ID is available.
  • provider: The payment provider used (mtn, airtel, or mpesa)
  • phone_number: The recipient phone number
  • recipient_name: The name of the recipient
  • amount: The transaction amount with formatted and raw values
  • mode: The transaction mode (e.g., mtnuganda, airteluganda, live, sandbox)
  • Top-level metadata (optional): Echo of request metadata from send-money — same rules as collections.

Successful Disbursement Payload (Airtel Example)

{
  "event_type": "disbursement.completed",
  "transaction": {
    "uuid": "transaction-uuid",
    "reference": "transaction-reference",
    "provider_reference": null,
    "status": "completed",
    "amount": {
      "formatted": "1,000.00",
      "raw": 1000,
      "currency": "UGX"
    },
    "provider": "airtel",
    "phone_number": "+256759983853",
    "recipient_name": "Katende Nicholas",
    "description": "Send Money to Katende Nicholas",
    "created_at": "2025-12-07T05:41:28.000000Z",
    "updated_at": "2025-12-07T05:42:05.000000Z"
  },
  "disbursement": {
    "provider": "airtel",
    "phone_number": "+256759983853",
    "amount": {
      "formatted": "1,000.00",
      "raw": 1000,
      "currency": "UGX"
    },
    "mode": "airteluganda",
    "provider_reference": null,
    "recipient_name": "Katende Nicholas",
    "provider_transaction_id": "AIRTEL_MONEY_ID"
  },
  "metadata": [
    { "orderId": "ORD-123456789" },
    { "customerId": "customer@email.com", "isPII": true }
  ]
}

Top-level metadata is present only when you sent it on POST /send-money.

Successful Disbursement Payload (MTN Example)

{
  "event_type": "disbursement.completed",
  "transaction": {
    "uuid": "transaction-uuid",
    "reference": "transaction-reference",
    "provider_reference": null,
    "status": "completed",
    "amount": {
      "formatted": "1,000.00",
      "raw": 1000,
      "currency": "UGX"
    },
    "provider": "mtn",
    "phone_number": "+256712345678",
    "recipient_name": "John Doe",
    "description": "Send Money to John Doe",
    "created_at": "2025-12-07T05:41:28.000000Z",
    "updated_at": "2025-12-07T05:42:05.000000Z"
  },
  "disbursement": {
    "provider": "mtn",
    "phone_number": "+256712345678",
    "amount": {
      "formatted": "1,000.00",
      "raw": 1000,
      "currency": "UGX"
    },
    "mode": "mtnuganda",
    "provider_reference": null,
    "recipient_name": "John Doe",
    "provider_transaction_id": "MTN_FINANCIAL_TRANSACTION_ID"
  },
  "metadata": [
    { "orderId": "ORD-123456789" },
    { "customerId": "customer@email.com", "isPII": true }
  ]
}

Successful Disbursement Payload (M-Pesa / Kenya Example)

{
  "event_type": "disbursement.completed",
  "transaction": {
    "uuid": "transaction-uuid",
    "reference": "transaction-reference",
    "provider_reference": "123e4567-e89b-12d3-a456-426614174001",
    "status": "completed",
    "amount": {
      "formatted": "100.00",
      "raw": 100,
      "currency": "KES"
    },
    "provider": "mpesa",
    "phone_number": "+254710000000",
    "recipient_name": null,
    "description": "Payout to customer",
    "created_at": "2026-08-01T14:30:00.000000Z",
    "updated_at": "2026-08-01T14:31:00.000000Z"
  },
  "disbursement": {
    "provider": "mpesa",
    "phone_number": "+254710000000",
    "amount": {
      "formatted": "100.00",
      "raw": 100,
      "currency": "KES"
    },
    "mode": "live",
    "provider_reference": "123e4567-e89b-12d3-a456-426614174001",
    "recipient_name": null,
    "provider_transaction_id": "ABC123"
  },
  "metadata": [
    { "orderId": "ORD-123456789" },
    { "customerId": "customer@email.com", "isPII": true }
  ]
}

Failed Disbursement Payload

{
  "event_type": "disbursement.failed",
  "transaction": {
    "uuid": "transaction-uuid",
    "reference": "transaction-reference",
    "provider_reference": null,
    "status": "failed",
    "amount": {
      "formatted": "1,000.00",
      "raw": 1000,
      "currency": "UGX"
    },
    "provider": "airtel",
    "phone_number": "+256759983853",
    "recipient_name": "Katende Nicholas",
    "description": "Send Money to Katende Nicholas",
    "created_at": "2025-12-07T05:41:28.000000Z",
    "updated_at": "2025-12-07T05:42:05.000000Z"
  },
  "disbursement": {
    "provider": "airtel",
    "phone_number": "+256759983853",
    "amount": {
      "formatted": "1,000.00",
      "raw": 1000,
      "currency": "UGX"
    },
    "mode": "airteluganda",
    "provider_reference": null,
    "recipient_name": "Katende Nicholas",
    "provider_transaction_id": "AIRTEL_MONEY_ID"
  },
  "metadata": [
    { "orderId": "ORD-123456789" },
    { "customerId": "customer@email.com", "isPII": true }
  ]
}

Best Practices

Always respond with HTTP 200 to acknowledge receipt of the callback

Implement idempotency to handle duplicate callbacks

Use the transaction reference to track and verify payments

Set up proper error handling and logging for callback processing

Chat on WhatsApp