> For the complete documentation index, see [llms.txt](https://moonie.gitbook.io/mooniepay/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://moonie.gitbook.io/mooniepay/introduction/webhooks.md).

# Webhooks

Webhooks allow MooniePay to notify your application in real time whenever an event occurs — such as a payment succeeding or a payout failing. Instead of polling the API, your server receives an HTTP POST request the moment something changes.

***

### How It Works

When an event is triggered (e.g., a payment is completed), MooniePay dispatches a signed HTTP POST request to the webhook URL configured on your API key. Your endpoint receives a JSON payload describing the event and must respond with a `2xx` status code to acknowledge receipt.

***

### Configuring Your Webhook URL

Webhook URLs are configured **per API key**, not globally per application. When you create a Payment or Payout API key in the dashboard, you set the webhook URL directly on that key. You can update it at any time or enable/disable the webhook without changing the URL.

<figure><img src="/files/BwT9Z6vPYNwIURiCG3p2" alt=""><figcaption></figcaption></figure>

To manage your webhook after creation:

1. Go to **Developer → API Keys** in your dashboard
2. Click on the key you want to configure
3. Update the **Webhook URL**, or use the toggle to **enable/disable** webhook delivery

<figure><img src="/files/T7t7k2nhTMGlqAuQIrNc" alt=""><figcaption></figcaption></figure>

> ⚠️ **Webhook URLs are scoped by key type and environment.** Your sandbox payment key will only fire webhooks to the URL set on that specific key — completely separate from your live or payout keys.

***

### Types of Events

MooniePay fires three webhook events, unified across both payments and payouts:

| Event                    | Description                                               |
| ------------------------ | --------------------------------------------------------- |
| `transaction.processing` | The transaction has been received and is being processed. |
| `transaction.completed`  | The transaction completed successfully.                   |
| `transaction.failed`     | The transaction failed or was rejected.                   |
| `transaction.cancelled`  | The transaction failed or was cancelled.                  |

> 💡 **There are no separate payment/payout event names.** A single set of `transaction.*` events covers both operation types. To know whether the event relates to a payment or a payout, inspect the `payload.type` field inside the webhook body.

***

### Webhook Payload Structure

Every webhook is sent as an HTTP `POST` request with a JSON body. All events share the same envelope structure — only the contents of `payload` vary.

#### `transaction.completed` — Payment

<pre class="language-json"><code class="lang-json">{
  "ref": "f83a0235-892b-44c0-bad5-a4e8f32ceb3d",
  "type": "transaction.completed",
  "payload": {
    "transaction_id": "TE_XXXXXXXXXXXXXXX",
    "ref": "f83a0235-892b-44c0-bad5-a4e8f32ceb3d",
    "external_id": "your_mechant_transaction_id",
    "type": "payment",
    "status": "completed",
    "amount": 5000,
    "currency": "XAF",
    "description": "Order #1234 payment",
    "account_name": "Jane Doe",
    "account_number": "6XXXXXXX",
    "account_type": "mobile_money",
    "reason": null,
    "failed_code": null,
    "failed_at": null,
    "cancelled_at": null,
    "completed_at": "2024-06-10T12:00:05.000000Z",
    "customer": {
      "ref": "cus_ref_xxxxxxxx",
      "first_name": "Jane",
      "last_name": "Doe",
      "email": "jane@example.com",
      "metadata": {
        "user_id": "42"
      }
    },
    "payment_method": {
      "ref": "f83a0235-892b-44c0-bad5-a4e8f32ceb3d",
      "name": "MTN Mobile Money",
      "code": "MOMO_CM"
    },
    "metadata": {
      "order_id": "ORD-9876",
      "user_id": "42"
    },
    "created_at": "2024-06-10T12:00:00.000000Z",
    "updated_at": "2024-06-10T12:00:05.000000Z"
  },
  "created_at": "2024-06-10T12:00:05.000000Z"
<strong>}
</strong></code></pre>

#### `transaction.failed` — Payout

When a transaction fails, the `reason` and `failed_code` Fields are populated to help you understand and communicate the failure.

```json
{
  "ref": "f83a0235-892b-44c0-bad5-a4e8f32ceb3d",
  "type": "transaction.failed",
  "payload": {
    "ref": "f83a0235-892b-44c0-bad5-a4e8f32ceb3d",
    "transaction_id": "TE_XXXXXXXXXXXXXXX",
    "external_id": "your_mechant_transaction_id",
    "type": "payout",
    "status": "failed",
    "amount": 10000,
    "currency": "XAF",
    "description": "Weekly supplier payout",
    "account_name": "John Smith",
    "account_number": "6XXXXXXX",
    "account_type": "mobile_money",
    "reason": "Recipient account not found or invalid.",
    "failed_code": "INVALID_ACCOUNT",
    "failed_at": "2024-06-10T13:05:10.000000Z",
    "cancelled_at": null,
    "completed_at": null,
    "customer": {
      "ref": "f83a0235-892b-44c0-bad5-a4e8f32ceb3d",
      "first_name": "John",
      "last_name": "Smith",
      "email": "john@example.com",
      "metadata": {}
    },
    "payment_method": {
      "ref": "f83a0235-892b-44c0-bad5-a4e8f32ceb3d",
      "name": "Orange Money",
      "code": "orange_money_cm"
    },
    "metadata": {
      "supplier_id": "SUP-001",
      "batch_ref": "BATCH-June-W2"
    },
    "created_at": "2024-06-10T13:00:00.000000Z",
    "updated_at": "2024-06-10T13:05:10.000000Z"
  },
  "created_at": "2024-06-10T13:05:10.000000Z"
}
```

#### `transaction.processing`

The `transaction.processing` event fires when MooniePay has received the transaction and submitted it to the payment provider. No financial outcome is confirmed yet. Use it to update a pending status in your UI — do not fulfil orders or release funds at this stage.

```json
{
  "ref": "f83a0235-892b-44c0-bad5-a4e8f32ceb3d",
  "type": "transaction.processing",
  "payload": {
    "ref": "f83a0235-892b-44c0-bad5-a4e8f32ceb3d",
    "type": "payment",
    "status": "processing",
    "environment": "live",
    "amount": 5000,
    "currency": "XAF",
    "description": "Order #1235 payment",
    "transaction_id": "your_internal_id_2",
    "external_id": null,
    "account_name": "Alice Martin",
    "account_number": "6XXXXXXX",
    "account_type": "mobile_money",
    "reason": null,
    "failed_code": null,
    "failed_at": null,
    "cancelled_at": null,
    "completed_at": null,
    "customer": {
      "ref": "f83a0235-892b-44c0-bad5-a4e8f32ceb3d",
      "first_name": "Alice",
      "last_name": "Martin",
      "email": "alice@example.com",
      "metadata": {}
    },
    "payment_method": {
      "ref": "f83a0235-892b-44c0-bad5-a4e8f32ceb3d",
      "name": "MTN Mobile Money",
      "code": "MOMO_CM"
    },
    "metadata": {
      "order_id": "ORD-9877"
    },
    "created_at": "2024-06-10T14:00:00.000000Z",
    "updated_at": "2024-06-10T14:00:02.000000Z"
  },
  "created_at": "2024-06-10T14:00:02.000000Z"
}
```

#### Payload Fields Reference

| Field                     | Type           | Description                                                                             |
| ------------------------- | -------------- | --------------------------------------------------------------------------------------- |
| `ref` *(envelope)*        | `string`       | Unique ID of the webhook event itself. Use this to deduplicate deliveries.              |
| `type` *(envelope)*       | `string`       | Event name: `transaction.processing`, `transaction.completed`, or `transaction.failed`. |
| `created_at` *(envelope)* | `string`       | ISO 8601 timestamp of when the event was dispatched.                                    |
| `payload.ref`             | `string`       | Public-facing transaction reference.                                                    |
| `payload.transaction_id`  | `string`       | Internal MooniePay transaction ID.                                                      |
| `payload.external_id`     | `string\|null` | The payment provider's own reference. Useful for support queries.                       |
| `payload.type`            | `string`       | **`payment` or `payout`** — use this to determine the operation type.                   |
| `payload.status`          | `string`       | `processing`, `completed`, or `failed`.                                                 |
| `payload.amount`          | `integer`      | Transaction amount in the smallest currency unit (e.g. cents, francs).                  |
| `payload.currency`        | `string`       | ISO 4217 currency code (e.g. `XAF`, `NGN`, `USD`).                                      |
| `payload.description`     | `string`       | Description set when the transaction was created.                                       |
| `payload.account_name`    | `string`       | Name on the account used for the transaction.                                           |
| `payload.account_number`  | `string`       | Account number / phone number used.                                                     |
| `payload.account_type`    | `string`       | Type of account (e.g. `mobile_money`, `bank`).                                          |
| `payload.reason`          | `string\|null` | Human-readable failure reason. Populated on `transaction.failed` only.                  |
| `payload.failed_code`     | `string\|null` | Machine-readable failure code. Populated on `transaction.failed` only.                  |
| `payload.failed_at`       | `string\|null` | ISO 8601 timestamp of when the failure occurred.                                        |
| `payload.cancelled_at`    | `string\|null` | ISO 8601 timestamp of when the transaction was cancelled.                               |
| `payload.completed_at`    | `string\|null` | ISO 8601 timestamp of when the transaction completed successfully.                      |
| `payload.customer`        | `object`       | The customer associated with the transaction (id, ref, name, email, metadata).          |
| `payload.payment_method`  | `object`       | The payment method used (id, ref, name, code).                                          |
| `payload.metadata`        | `object\|null` | Your own metadata, echoed back as provided when the transaction was created.            |
| `payload.created_at`      | `string`       | ISO 8601 timestamp of when the transaction was created.                                 |
| `payload.updated_at`      | `string`       | ISO 8601 timestamp of the last update to the transaction.                               |

***

### Request Headers

Every webhook request includes the following headers:

| Header             | Description                                                    |
| ------------------ | -------------------------------------------------------------- |
| `Content-Type`     | Always `application/json`                                      |
| `X-TTUM-Signature` | HMAC-SHA256 signature of the payload (see Verifying a Webhook) |
| `X-TTUM-Timestamp` | Unix timestamp (seconds) when the webhook was dispatched       |
| `X-TTUM-Event-Ref` | The unique `ref` of the webhook event                          |

***

### Verifying a Webhook

Because your webhook URL is publicly reachable, you **must verify** the `X-TTUM-Signature` header to confirm the request genuinely came from MooniePay and has not been tampered with.

#### How the Signature is Built

MooniePay signs each webhook using the same HMAC-SHA256 canonical string format used for API request authentication:

```
{publicKey}.{timestamp}.POST.{path}.{jsonPayload}
```

| Part          | Value                                                               |
| ------------- | ------------------------------------------------------------------- |
| `publicKey`   | The public key of the API key that owns this webhook                |
| `timestamp`   | The value of the `X-TTUM-Timestamp` header                          |
| `method`      | Always `POST`                                                       |
| `path`        | The path component of your webhook URL (e.g. `/webhooks/mooniepay`) |
| `jsonPayload` | The raw JSON body string, encoded with `JSON_UNESCAPED_SLASHES`     |

The signature is signed with the **secret key** of the API key, then hex-encoded.

#### Verification Examples

{% tabs %}
{% tab title="PHP" %}

```php
<?php

$payload   = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_TTUM_TIMESTAMP'] ?? '';
$received  = $_SERVER['HTTP_X_TTUM_SIGNATURE'] ?? '';
$publicKey = 'pk_live_xxxxxxxxxxxx'; // Your API key's public key
$secretKey = 'your_secret_key_here';

// Reject if timestamp is outside ±5 minutes
if (abs(time() - (int)$timestamp) > 300) {
    http_response_code(403);
    exit('Timestamp expired.');
}

// Rebuild the canonical string
$path         = parse_url('https://yourdomain.com/webhooks/mooniepay', PHP_URL_PATH);
$stringToSign = "{$publicKey}.{$timestamp}.POST.{$path}.{$payload}";

// Compute expected signature
$expected = hash_hmac('sha256', $stringToSign, $secretKey);

if (hash_equals($expected, $received)) {
    http_response_code(200);
    // ✅ Process the event
} else {
    http_response_code(403);
    exit('Invalid signature.');
}
```

{% endtab %}

{% tab title="Node.js(expressJs)" %}

```js
const crypto = require('crypto');

app.post('/webhooks/mooniepay', express.raw({ type: 'application/json' }), (req, res) => {
  const payload    = req.body.toString(); // raw body as string
  const timestamp  = req.headers['x-ttum-timestamp'];
  const received   = req.headers['x-ttum-signature'];
  const publicKey  = 'pk_live_xxxxxxxxxxxx';
  const secretKey  = 'your_secret_key_here';
  const path       = '/webhooks/mooniepay';

  // Reject stale requests
  if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
    return res.status(403).send('Timestamp expired.');
  }

  // Rebuild canonical string
  const stringToSign = `${publicKey}.${timestamp}.POST.${path}.${payload}`;

  // Compute expected signature
  const expected = crypto
    .createHmac('sha256', secretKey)
    .update(stringToSign)
    .digest('hex');

  if (crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(received))) {
    res.sendStatus(200);
    // ✅ Process the event asynchronously
  } else {
    res.status(403).send('Invalid signature.');
  }
});
```

{% endtab %}

{% tab title="Python" %}

```python
import hmac
import hashlib
import time
from flask import Flask, request, abort

app = Flask(__name__)

PUBLIC_KEY = 'pk_live_xxxxxxxxxxxx'
SECRET_KEY = 'your_secret_key_here'
WEBHOOK_PATH = '/webhooks/mooniepay'

@app.route('/webhooks/mooniepay', methods=['POST'])
def receive_webhook():
    payload   = request.get_data(as_text=True)
    timestamp = request.headers.get('X-TTUM-Timestamp', '')
    received  = request.headers.get('X-TTUM-Signature', '')

    # Reject stale requests
    if abs(time.time() - int(timestamp)) > 300:
        abort(403, 'Timestamp expired.')

    # Rebuild canonical string
    string_to_sign = f"{PUBLIC_KEY}.{timestamp}.POST.{WEBHOOK_PATH}.{payload}"

    # Compute expected signature
    expected = hmac.new(
        SECRET_KEY.encode('utf-8'),
        string_to_sign.encode('utf-8'),
        hashlib.sha256
    ).hexdigest()

    if hmac.compare_digest(expected, received):
        # ✅ Process the event asynchronously
        return '', 200
    else:
        abort(403, 'Invalid signature.')
```

{% endtab %}

{% tab title="Laravel(PHP)" %}

```php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class WebhookController extends Controller
{
    // Add your route to the CSRF exception list in bootstrap/app.php
    public function handle(Request $request)
    {
        $payload   = $request->getContent();
        $timestamp = $request->header('X-TTUM-Timestamp');
        $received  = $request->header('X-TTUM-Signature');
        $publicKey = 'pk_live_xxxxxxxxxxxx';
        $secretKey = config('services.mooniepay.secret');

        // Reject stale requests
        if (abs(time() - (int)$timestamp) > 300) {
            return response('Timestamp expired.', 403);
        }

        // Rebuild canonical string
        $path         = parse_url(config('services.mooniepay.webhook_url'), PHP_URL_PATH);
        $stringToSign = "{$publicKey}.{$timestamp}.POST.{$path}.{$payload}";

        // Compute expected signature
        $expected = hash_hmac('sha256', $stringToSign, $secretKey);

        if (! hash_equals($expected, $received)) {
            return response('Invalid signature.', 403);
        }

        $event  = $request->json('type');           // "transaction.completed" etc.
        $txType = $request->json('payload.type');   // "payment" or "payout"
        $txId   = $request->json('payload.id');

        // Route by event + transaction type
        match (true) {
            $event === 'transaction.processing' => // mark as processing in your DB,
            $event === 'transaction.completed' && $txType === 'payment' => // fulfil order,
            $event === 'transaction.completed' && $txType === 'payout'  => // confirm payout,
            $event === 'transaction.failed'    && $txType === 'payment' => // notify customer,
            $event === 'transaction.failed'    && $txType === 'payout'  => // alert finance team,
            default => null,
        };

        return response('OK', 200);
    }
}
```

{% endtab %}
{% endtabs %}

> ⚠️ **Always use a constant-time comparison** (`hash_equals` in PHP, `crypto.timingSafeEqual` in Node.js, `hmac.compare_digest` in Python) to prevent timing attacks.

> ⚠️ **Exempt your webhook endpoint from CSRF protection.** Frameworks like Laravel and Django apply CSRF checks to all POST routes by default. Add your webhook route to the CSRF exception list.

***

### Retry Schedule

If your endpoint does not return a `2xx` response — or does not respond within **20 seconds** — MooniePay will retry the delivery automatically using an exponential backoff schedule.

| Attempt   | Delay after previous attempt |
| --------- | ---------------------------- |
| 1         | Immediate                    |
| 2         | 30 seconds                   |
| 3         | 2 minutes                    |
| 4         | 10 minutes                   |
| 5         | 1 hour                       |
| 6         | 3 hours                      |
| 7         | 12 hours                     |
| 8 (final) | 24 hours                     |

**Maximum attempts: 7.** If all attempts fail, the event is marked as `failed` and no further retries are made.

#### Retry Behaviour by Response Code

| Scenario                         | Behaviour                                                                    |
| -------------------------------- | ---------------------------------------------------------------------------- |
| `2xx` response                   | ✅ Marked as `delivered`. No further attempts.                                |
| `429 Too Many Requests`          | ⏳ Respects `Retry-After` header if present, otherwise uses backoff schedule. |
| `4xx` (other than 429)           | ❌ Marked as `failed` immediately. Client errors are not retried.             |
| `5xx` response                   | 🔁 Rescheduled using backoff.                                                |
| Connection timeout / no response | 🔁 Rescheduled using backoff.                                                |

> 💡 **Respond fast.** Your endpoint must respond within **20 seconds**. If you need to run long-running tasks (database writes, third-party calls), respond with `200` immediately and process the payload asynchronously via a queue.

***

### Auto-Disable on Failure

If a webhook event exhausts all 7 retry attempts without a successful delivery, MooniePay will **automatically disable** the webhook on that API key and notify your application's admin users by email.

This prevents a broken endpoint from silently accumulating failed events. Once you have fixed your endpoint:

1. Go to **Developer → API Keys** in your dashboard
2. Re-enable the webhook toggle on the affected key
3. Manually retry any failed events from the **Webhook Event Logs**

***

### Webhook Event Logs

MooniePay records every webhook delivery attempt in your dashboard. For each attempt you can see:

* The attempt number and timestamp
* The HTTP response status code returned by your endpoint
* The full response body (truncated to 64KB)
* The request headers and body that were sent

<figure><img src="/files/jvzVvudJg8hcZR2BGEOO" alt=""><figcaption></figcaption></figure>

Failed events can be **manually retried** from the logs at any time, even after the webhook has been auto-disabled.

<figure><img src="/files/udcMmaQ8qLBmxk3aj3sC" alt=""><figcaption></figcaption></figure>

***

### Webhook Best Practices

**Return `200` immediately, process asynchronously.** Your endpoint must respond within 20 seconds. Push the work to a background queue and acknowledge receipt straight away.

**Verify the signature on every request.** Your webhook URL is publicly accessible. Always validate `X-TTUM-Signature` before acting on any payload.

**Use `ref` to deduplicate.** MooniePay may deliver the same event more than once. Track the `ref` field in your database and skip events you have already processed.

**Always re-query the API.** Use the payload to identify the resource, then fetch its current state from the API before acting. Never trust the payload status alone.

**Use a sandbox for end-to-end testing.** Webhooks fire in sandbox mode, too. Use [ngrok](https://ngrok.com/), [Hookdeck](https://hookdeck.com/), or [Expose](https://expose.dev/) to tunnel your local endpoint during development.

**Have a fallback polling job.** Webhooks can fail. Run a periodic background job that queries for any transactions still in a `pending` state and reconciles them — especially for payments older than a few minutes with no status update.

**Keep your endpoint idempotent.** Retries and duplicates will happen. Design your handler so that processing the same event twice has no harmful side effects.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://moonie.gitbook.io/mooniepay/introduction/webhooks.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
