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

# Manage PeerPesa webhooks and verify event signatures

> Register webhook endpoints, subscribe to PeerPesa transaction, deposit, quote, and KYC events, verify signatures, and handle retries in your server.

PeerPesa webhooks push real-time notifications to your server when something changes, such as a deposit confirming or an offramp payout completing. Instead of polling `GET /transactions`, register an HTTPS endpoint and PeerPesa sends a signed `POST` request for every event you subscribe to. This page covers managing endpoints, the event catalog, signature verification, and retries.

## How webhooks work

<Steps>
  <Step title="Register an endpoint">
    Enter your webhook URL in the dashboard. PeerPesa checks that the URL is valid and returns `200`, then gives you a webhook key.
  </Step>

  <Step title="Receive events">
    When an event occurs, PeerPesa sends a `POST` with a JSON body, signed with your webhook key in the `X-Signature` and `X-Timestamp` headers.
  </Step>

  <Step title="Verify and acknowledge">
    Verify the signature, return a `2xx` status within 10 seconds, then process the event asynchronously.
  </Step>
</Steps>

<Note>
  Sandbox and production have separate webhook URLs and separate webhook keys. Register a URL in each environment, and verify each environment's events with that environment's webhook key.
</Note>

## Set up your webhook in the dashboard

<Steps>
  <Step title="Prepare your endpoint">
    Deploy a URL on your server that accepts `POST` requests and responds with HTTP `200`. It must be a valid, publicly reachable URL.
  </Step>

  <Step title="Enter the URL">
    In the PeerPesa dashboard, open **Settings** > **Webhooks** and enter your webhook URL.
  </Step>

  <Step title="Pass URL validation">
    PeerPesa validates the URL format and sends a test request to it. The URL is saved only if your endpoint returns `200`.
  </Step>

  <Step title="Copy your webhook key">
    After the URL is saved, the dashboard shows your webhook key. Store it securely, for example in an environment variable such as `PEERPESA_WEBHOOK_KEY`. You use it to [verify signatures](#signature-verification).
  </Step>
</Steps>

<Warning>
  If the URL is malformed, unreachable, or returns anything other than `200` (including redirects, `201`, or `204`), the dashboard rejects it. Make sure your handler is live and returns `200` before you save the URL.
</Warning>

<Tip>
  You do not have the webhook key until the URL is saved, so the validation request cannot be verified yet. Have your handler return `200` at setup, then turn on signature checks once you have stored the key.
</Tip>

## Manage webhook endpoints

Register your webhook URL and get your webhook key in the dashboard, as described above. The endpoints below let you manage webhooks with the API. The same URL rules apply: the URL must be valid and return `200`.

### POST /webhooks

Registers a new endpoint.

<ParamField body="url" type="string" required>
  Public HTTPS URL that receives events.
</ParamField>

<ParamField body="events" type="string[]" required>
  Event types to subscribe to. Use `["*"]` to receive every event.
</ParamField>

<ParamField body="description" type="string">
  Label to identify the endpoint.
</ParamField>

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.peerpesa.co/v1/webhooks \
    -H "Authorization: Bearer $ACCESS_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
      "url": "https://example.com/peerpesa/webhooks",
      "events": ["transaction.completed", "transaction.failed", "deposit.confirmed"],
      "description": "Production payouts"
    }'
  ```

  ```javascript JavaScript theme={null}
  const res = await fetch("https://api.peerpesa.co/v1/webhooks", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      url: "https://example.com/peerpesa/webhooks",
      events: ["transaction.completed", "transaction.failed", "deposit.confirmed"],
      description: "Production payouts",
    }),
  });
  const webhook = await res.json();
  ```
</CodeGroup>

```json Response theme={null}
{
  "id": "wh_5k2n8r",
  "url": "https://example.com/peerpesa/webhooks",
  "events": ["transaction.completed", "transaction.failed", "deposit.confirmed"],
  "description": "Production payouts",
  "status": "enabled",
  "created_at": "2026-10-15T05:00:00Z"
}
```

### Other endpoints

| Method | Path | Description |
| - | - | - |
| `GET` | `/webhooks` | List all endpoints |
| `GET` | `/webhooks/{id}` | Retrieve one endpoint |
| `PATCH` | `/webhooks/{id}` | Update `url`, `events`, `description`, or `status` (`enabled` / `disabled`) |
| `DELETE` | `/webhooks/{id}` | Delete an endpoint. Returns `204` |
| `POST` | `/webhooks/{id}/test` | Send a `webhook.test` event to the endpoint |
| `GET` | `/webhooks/{id}/deliveries` | List recent delivery attempts with status codes. Supports [pagination and filters](/pagination) |

## Event types

### Transactions

| Event | Sent when |
| - | - |
| `transaction.created` | An onramp or offramp transaction is created |
| `transaction.awaiting_payment` | An onramp is waiting for the payer's fiat payment |
| `transaction.awaiting_deposit` | An offramp is waiting for the crypto deposit |
| `transaction.processing` | Payment or deposit received and conversion or payout started |
| `transaction.completed` | Crypto delivered (onramp) or fiat paid out (offramp) |
| `transaction.failed` | The transaction failed. See `failure_code` |
| `transaction.expired` | The quote expired before payment or deposit arrived |
| `transaction.refunded` | Funds were returned to the payer or `refund_address` |

### Deposits and wallets

| Event | Sent when |
| - | - |
| `wallet.created` | A deposit wallet or Lightning address is created |
| `deposit.detected` | An incoming deposit is seen on-chain but not yet confirmed |
| `deposit.confirmed` | A deposit reaches the required confirmations and is credited |
| `deposit.failed` | A deposit could not be credited (for example, wrong asset or missing Stellar memo) |
| `lightning.invoice_paid` | A Lightning invoice was paid |
| `lightning.invoice_expired` | A Lightning invoice expired unpaid |

### Quotes, beneficiaries, and account

| Event | Sent when |
| - | - |
| `quote.expired` | A quote passed its 30-minute validity window unused |
| `beneficiary.created` | A beneficiary was added |
| `beneficiary.deleted` | A beneficiary was removed |
| `kyc.approved` | KYC verification was approved |
| `kyc.action_required` | Compliance needs more information |
| `kyc.rejected` | KYC verification was rejected |
| `network.status_changed` | A network moved between `operational`, `degraded`, and `down` |
| `webhook.test` | Sent by `POST /webhooks/{id}/test` |

### Recurring payments

| Event | Sent when |
| - | - |
| `recurring_payment.activated` | A recurring payment was reviewed and activated |
| `recurring_payment.run_upcoming` | A run is scheduled within the next 24 hours |
| `recurring_payment.run_succeeded` | A scheduled run created its transaction successfully |
| `recurring_payment.run_failed` | A scheduled run failed, for example `insufficient_balance` |
| `recurring_payment.paused` | A recurring payment was paused manually or after repeated failures |
| `recurring_payment.completed` | The final scheduled run finished |
| `recurring_payment.restarted` | A paused recurring payment was restarted |
| `recurring_payment.deleted` | A recurring payment was deleted |

## Event payload

Every event uses the same envelope. `data.object` contains the full resource at the time of the event.

```json transaction.completed theme={null}
{
  "id": "evt_2p9x4m",
  "type": "transaction.completed",
  "created_at": "2026-10-15T05:12:43Z",
  "livemode": true,
  "data": {
    "object": {
      "id": "txn_9d4h2p",
      "type": "offramp",
      "status": "completed",
      "network": "stellar",
      "asset": "USDC",
      "fiat_currency": "KES",
      "source_amount": "100.00",
      "destination_amount": "12750.83",
      "fee": { "total": "1.50", "currency": "USDC", "processing_fee": "1.00", "network_fee": "0.50" },
      "tx_hash": "a3f9c2...e81b",
      "reference": "payout-1042"
    }
  }
}
```

<ResponseField name="id" type="string">Unique event ID. Use it to deduplicate.</ResponseField>
<ResponseField name="type" type="string">Event type from the tables above.</ResponseField>
<ResponseField name="livemode" type="boolean">`true` for production, `false` for sandbox.</ResponseField>
<ResponseField name="data.object" type="object">The resource the event describes.</ResponseField>

## Signature verification

Every webhook PeerPesa sends is signed so you can confirm it came from PeerPesa and was not modified in transit. Signed webhooks include two headers:

| Header | Value |
| - | - |
| `X-Signature` | HMAC-SHA256 hex digest |
| `X-Timestamp` | Unix timestamp in milliseconds |

The signature is computed with the webhook key shown in the dashboard after you saved your webhook URL:

```text Signature formula theme={null}
HMAC-SHA256(key = webhook_key, data = "<X-Timestamp>.<JSON body>")
```

For example, if `X-Timestamp` is `1784784828000` and the body is `{"id":"evt_2p9x4m",...}`, the signed string is `1784784828000.{"id":"evt_2p9x4m",...}`.

<Steps>
  <Step title="Read the headers">
    Get `X-Signature` and `X-Timestamp` from the request. Reject the request if either is missing.
  </Step>

  <Step title="Build the signed string">
    Join the `X-Timestamp` value, a period (`.`), and the raw JSON request body exactly as received.
  </Step>

  <Step title="Compute the expected signature">
    Compute HMAC-SHA256 of the signed string using your webhook key, and hex-encode the result.
  </Step>

  <Step title="Compare and check freshness">
    Compare your result with `X-Signature` using a constant-time comparison. Reject events whose `X-Timestamp` is more than 5 minutes from your server time to block replayed requests.
  </Step>
</Steps>

<CodeGroup>
  ```javascript Node.js (Express) theme={null}
  import crypto from "crypto";
  import express from "express";

  const app = express();
  const WEBHOOK_KEY = process.env.PEERPESA_WEBHOOK_KEY;
  const TOLERANCE_MS = 5 * 60 * 1000;

  app.post("/peerpesa/webhooks", express.raw({ type: "application/json" }), (req, res) => {
    const signature = req.get("X-Signature");
    const timestamp = req.get("X-Timestamp");
    if (!signature || !timestamp) return res.status(400).send("Missing signature");

    // Reject old or replayed requests (timestamp is in milliseconds)
    if (Math.abs(Date.now() - Number(timestamp)) > TOLERANCE_MS) {
      return res.status(400).send("Stale timestamp");
    }

    const rawBody = req.body.toString("utf8");
    const expected = crypto
      .createHmac("sha256", WEBHOOK_KEY)
      .update(`${timestamp}.${rawBody}`)
      .digest("hex");

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

    if (!valid) return res.status(401).send("Invalid signature");

    const event = JSON.parse(rawBody);
    res.sendStatus(200); // acknowledge first
    handleEvent(event);  // then process asynchronously
  });
  ```

  ```python Python (Flask) theme={null}
  import hashlib
  import hmac
  import os
  import time

  from flask import Flask, abort, request

  app = Flask(__name__)
  WEBHOOK_KEY = os.environ["PEERPESA_WEBHOOK_KEY"].encode()
  TOLERANCE_MS = 5 * 60 * 1000

  @app.post("/peerpesa/webhooks")
  def peerpesa_webhook():
      signature = request.headers.get("X-Signature")
      timestamp = request.headers.get("X-Timestamp")
      if not signature or not timestamp:
          abort(400)

      if abs(time.time() * 1000 - int(timestamp)) > TOLERANCE_MS:
          abort(400)

      raw_body = request.get_data(as_text=True)
      expected = hmac.new(
          WEBHOOK_KEY, f"{timestamp}.{raw_body}".encode(), hashlib.sha256
      ).hexdigest()

      if not hmac.compare_digest(expected, signature):
          abort(401)

      event = request.get_json()
      # process the event asynchronously
      return "", 200
  ```
</CodeGroup>

<Warning>
  Sign the raw request body exactly as received. Parsing and re-serializing the JSON changes spacing and key order, which produces a different signature.
</Warning>

<Note>
  The webhook key is separate from your API keys. Never use it to authenticate API requests, and never expose it in client-side code. If you change your webhook URL or the key is regenerated in the dashboard, update the key in your handler.
</Note>

## Retries and best practices

* Return a `2xx` status within **10 seconds**. Any other response or a timeout counts as a failure.
* Failed deliveries are retried with exponential backoff for up to **3 days** (about 1m, 5m, 30m, 2h, 6h, then every 12h).
* After repeated failures, the endpoint is set to `disabled` and you are notified by email. Re-enable it with `PATCH /webhooks/{id}`.
* Events can arrive more than once or out of order. Deduplicate on event `id` and re-fetch the resource with the API if you need the latest state.
* Only accept requests with a valid signature, and use HTTPS for every endpoint.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.