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

# Webhooks

> Receive signed real-time notifications when payments succeed

# Webhooks

PayRequest can send a signed HTTP POST to your endpoint when a payment succeeds. Use this to automate fulfillment, update your database, or trigger downstream workflows without polling.

## Setup

1. Go to **Settings → API & MCP** in your PayRequest dashboard
2. Scroll down to the **Webhooks** section
3. Enter your endpoint URL (must be publicly reachable over HTTPS)
4. Click **Save** — a signing secret is generated automatically
5. Copy the **Signing Secret** and store it securely in your environment

***

## Verifying signatures

Every request includes an `X-PayRequest-Signature` header containing an HMAC-SHA256 signature of the raw request body. **Always verify this before processing the event.**

```
X-PayRequest-Signature: sha256=a1b2c3d4...
```

<CodeGroup>
  ```php PHP theme={null}
  $secret   = $_ENV['PAYREQUEST_WEBHOOK_SECRET'];
  $body     = file_get_contents('php://input');
  $received = $_SERVER['HTTP_X_PAYREQUEST_SIGNATURE'] ?? '';
  $expected = 'sha256=' . hash_hmac('sha256', $body, $secret);

  if (!hash_equals($expected, $received)) {
      http_response_code(401);
      exit;
  }

  $event = json_decode($body, true);
  ```

  ```python Python theme={null}
  import hashlib, hmac, os
  from flask import request, abort

  secret = os.environ['PAYREQUEST_WEBHOOK_SECRET'].encode()
  body   = request.get_data()
  sig    = request.headers.get('X-PayRequest-Signature', '')

  expected = 'sha256=' + hmac.new(secret, body, hashlib.sha256).hexdigest()
  if not hmac.compare_digest(expected, sig):
      abort(401)

  event = request.get_json()
  ```

  ```javascript Node.js theme={null}
  const crypto = require('crypto');

  const secret = process.env.PAYREQUEST_WEBHOOK_SECRET;
  const body   = req.rawBody; // use raw body, not parsed JSON
  const sig    = req.headers['x-payrequest-signature'] ?? '';

  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex');

  if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
    return res.status(401).send('Invalid signature');
  }

  const event = JSON.parse(body);
  ```
</CodeGroup>

<Warning>
  Always use a timing-safe comparison (`hash_equals`, `hmac.compare_digest`, `timingSafeEqual`) to prevent timing attacks.
</Warning>

***

## Event: `payment.succeeded`

Fired when a transaction status changes to `paid`. Delivered asynchronously via the queue (typically within seconds).

### Payload

```json theme={null}
{
  "event": "payment.succeeded",
  "timestamp": "2026-05-30T12:00:00+02:00",
  "data": {
    "id": 1234,
    "amount": 49.00,
    "currency": "EUR",
    "description": "JMIT-githubuser",
    "payment_link_id": 42,
    "payment_method": "ideal",
    "reference": "tr_abc123",
    "paid_at": "2026-05-30T11:59:45+02:00",
    "customer": {
      "id": 99,
      "name": "Jane Doe",
      "email": "jane@example.com"
    }
  }
}
```

### Fields

| Field                  | Type          | Description                                      |
| ---------------------- | ------------- | ------------------------------------------------ |
| `event`                | string        | Always `payment.succeeded`                       |
| `timestamp`            | ISO 8601      | When the event was dispatched                    |
| `data.id`              | integer       | Transaction ID                                   |
| `data.amount`          | decimal       | Amount paid (e.g. `49.00`)                       |
| `data.currency`        | string        | 3-letter ISO currency code                       |
| `data.description`     | string        | Your custom reference (e.g. `JMIT-githubuser`)   |
| `data.payment_link_id` | integer\|null | ID of the Smart Link used, if any                |
| `data.payment_method`  | string        | Payment method used (e.g. `ideal`, `creditcard`) |
| `data.reference`       | string        | Payment provider transaction reference           |
| `data.paid_at`         | ISO 8601      | Exact time the payment was confirmed             |
| `data.customer`        | object\|null  | Customer name, email, and ID                     |

***

## Retries

Webhook delivery is handled by the queue with **3 attempts** and a **60-second backoff** between retries. If all attempts fail, the event is dropped and a warning is logged.

Return any `2xx` status code to acknowledge receipt.

***

## Rotating the secret

If your secret is compromised, go to **Settings → API & MCP → Webhooks** and click **Regenerate**. Your old secret stops working immediately — update your environment variable before rotating.

***

## Testing locally

Use a tunnel tool to expose your local server during development:

```bash theme={null}
# Using ngrok
ngrok http 8000

# Then set your webhook URL to:
# https://abc123.ngrok.io/webhooks/payrequest
```
