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

# Webhooks

Payr sends webhook notifications to your configured endpoint when payment events occur. Webhooks are signed with HMAC-SHA256 and retried on failure.

> Contact **<support@mypayr.co.uk>** to configure your webhook URL and receive your webhook secret.

#### Event Types

| Event             | Status      | Description                                  |
| ----------------- | ----------- | -------------------------------------------- |
| `payment_success` | `completed` | Payment was completed successfully           |
| `payment_failed`  | `failed`    | Payment was declined or failed               |
| `payment_pending` | `pending`   | Payment is awaiting confirmation (e.g., 3DS) |

#### Common Payload Fields

All webhook payloads include these fields:

| Field            | Type    | Description                                                  |
| ---------------- | ------- | ------------------------------------------------------------ |
| `event`          | string  | Event type                                                   |
| `student_ref`    | string  | Your external student/tenant reference                       |
| `payment_id`     | string  | Payr payment ID                                              |
| `amount`         | integer | Payment amount in **minor units** (pence). `85000` = £850.00 |
| `currency`       | string  | Currency code (e.g., `"GBP"`)                                |
| `timestamp`      | string  | ISO 8601 timestamp                                           |
| `payment_method` | string  | Payment method (e.g., `"card"`)                              |
| `transaction_id` | string  | Acquirer transaction ID                                      |
| `status`         | string  | `"completed"`, `"failed"`, or `"pending"`                    |

#### Full Payload Examples

**`payment_success`:**

```json
{
  "event": "payment_success",
  "student_ref": "12345",
  "payment_id": "abc-123-def",
  "amount": 85000,
  "currency": "GBP",
  "timestamp": "2024-09-01T12:00:00Z",
  "payment_method": "card",
  "transaction_id": "acq_txn_456",
  "status": "completed",
  "schedule_activated": true,
  "schedule_id": "01KH1G00N088163Y0H9WBJTT95",
  "next_installment_date": "2024-12-15"
}
```

| Extra Field             | Type           | Description                                               |
| ----------------------- | -------------- | --------------------------------------------------------- |
| `schedule_activated`    | boolean        | Whether an installment schedule was activated             |
| `schedule_id`           | string         | Schedule ID (present when `schedule_activated` is `true`) |
| `next_installment_date` | string or null | Next installment due date                                 |

**`payment_failed`:**

```json
{
  "event": "payment_failed",
  "student_ref": "12345",
  "payment_id": "abc-123-ghi",
  "amount": 85000,
  "currency": "GBP",
  "timestamp": "2024-09-01T12:05:00Z",
  "payment_method": "card",
  "transaction_id": "",
  "status": "failed",
  "error_code": "card_declined",
  "error_message": "Payment failed"
}
```

| Extra Field     | Type   | Description                      |
| --------------- | ------ | -------------------------------- |
| `error_code`    | string | Machine-readable error code      |
| `error_message` | string | Human-readable error description |

**`payment_pending`:**

```json
{
  "event": "payment_pending",
  "student_ref": "12345",
  "payment_id": "abc-123-jkl",
  "amount": 85000,
  "currency": "GBP",
  "timestamp": "2024-09-01T12:03:00Z",
  "payment_method": "card",
  "transaction_id": "",
  "status": "pending",
  "pending_reason": "3ds_authentication_pending"
}
```

| Extra Field      | Type   | Description                   |
| ---------------- | ------ | ----------------------------- |
| `pending_reason` | string | Reason the payment is pending |

#### Signature Verification

All webhooks include an `X-Payr-Signature` header containing an HMAC-SHA256 signature. **Always verify the signature** before processing the payload.

**Steps:**

1. Serialize the JSON payload with compact separators (no spaces) and sorted keys
2. Compute HMAC-SHA256 using your webhook secret
3. Compare the result with the `X-Payr-Signature` header value using a timing-safe comparison

**Python:**

```python
import hmac
import hashlib
import json

body_str = json.dumps(payload, separators=(",", ":"), sort_keys=True)
expected = hmac.new(
    webhook_secret.encode("utf-8"),
    body_str.encode("utf-8"),
    hashlib.sha256
).hexdigest()

is_valid = hmac.compare_digest(expected, request.headers["X-Payr-Signature"])
```

**Node.js:**

```javascript
const crypto = require("crypto");

const bodyStr = JSON.stringify(payload, Object.keys(payload).sort());
// Or if receiving raw body: use the raw request body string directly
const expected = crypto
  .createHmac("sha256", webhookSecret)
  .update(bodyStr)
  .digest("hex");

const signature = req.headers["x-payr-signature"];
const isValid = crypto.timingSafeEqual(
  Buffer.from(expected),
  Buffer.from(signature)
);
```

> **Important for Node.js:** The payload must be serialized as compact JSON with sorted keys to match the signature. If possible, verify against the **raw request body** string rather than re-serializing.

#### Retry Policy

If your endpoint returns a non-2xx status or is unreachable, Payr retries up to **3 times** with increasing backoff:

| Attempt | Delay      |
| ------- | ---------- |
| 1       | 1 second   |
| 2       | 5 seconds  |
| 3       | 15 seconds |

Your endpoint should return a `200` status code to acknowledge receipt. All delivery attempts (successful and failed) are logged on Payr's side.

***
