# Webhooks
Source: https://paylinky.vercel.app/docs/webhooks

Receive signed events when payments change status.

# Webhooks

Set your default endpoint in **Developers → Webhooks**, or send `notify_url` with a payment. The URL must be public `http(s)` — `localhost` and private networks are blocked.

## Events

| Event | When |
| - | - |
| `payment.completed` | Payment confirmed (`status: "success"`) |
| `payment.failed` | Payment `failed`, `cancelled` or `expired` |

## Request

```http
POST https://yourapp.com/webhooks/paylinky
Content-Type: application/json
X-Paylinky-Event-Id: 6f1c0f2e-…
X-Paylinky-Timestamp: 1735643554
X-Paylinky-Signature: sha256=3b6a…
```

```json
{
  "event": "payment.completed",
  "transaction_id": "0b9c7f0e-2d0b-4d76-9f5a-6b8a1c1d2e3f",
  "reference": "PLK-7XK2M9QPLA",
  "merchant_reference": "order_cm_12345",
  "status": "success",
  "payment_method": "mobile_money",
  "amount": 5000,
  "fee_amount": 300,
  "credited_amount": 4700,
  "currency": "XAF",
  "country_code": "CM",
  "operator": "MTN Mobile Money",
  "phone": "+2376XXXXXXXX",
  "sandbox": false,
  "created_at": "2026-01-01T10:00:00.000Z",
  "expires_at": null,
  "timestamp": "2026-01-01T10:00:41.000Z"
}
```

## Verify the signature

The signature is a hex HMAC-SHA256 of `timestamp + "." + raw_request_body` using your `whsec_…` secret. Verify against the **raw body** *before* JSON parsing and reject timestamps older than 5 minutes.

:::tabs
```javascript Node.js (Express)
const { Paylinky } = require("./paylinky.js");

app.post("/webhooks/paylinky", express.raw({ type: "application/json" }), async (req, res) => {
  const raw = req.body.toString("utf8");
  if (!Paylinky.verifyWebhook(raw, req.headers, process.env.PAYLINKY_WEBHOOK_SECRET)) return res.sendStatus(401);
  res.sendStatus(200); // acknowledge quickly
  const event = JSON.parse(raw);
  if (event.event === "payment.completed") await fulfillOrderOnce(event.merchant_reference, event.transaction_id);
});
```
```python Python (Flask)
from paylinky import Paylinky

@app.post("/webhooks/paylinky")
def hook():
    raw = request.get_data()
    if not Paylinky.verify_webhook(raw, request.headers, os.environ["PAYLINKY_WEBHOOK_SECRET"]):
        return "", 401
    event = json.loads(raw)
    if event["event"] == "payment.completed":
        fulfill_once(event["merchant_reference"], event["transaction_id"])
    return "", 200
```
```php PHP
<?php
require "Paylinky.php";
$raw = file_get_contents("php://input");
$headers = ["X-Paylinky-Timestamp" => $_SERVER["HTTP_X_PAYLINKY_TIMESTAMP"] ?? "", "X-Paylinky-Signature" => $_SERVER["HTTP_X_PAYLINKY_SIGNATURE"] ?? ""];
if (!Paylinky::verifyWebhook($raw, $headers, getenv("PAYLINKY_WEBHOOK_SECRET"))) { http_response_code(401); exit; }
http_response_code(200);
$event = json_decode($raw, true);
```
:::

## Best practices

1. Respond `2xx` quickly (within 8 seconds).
2. Verify the signature with the raw body.
3. Make processing **idempotent** using `transaction_id` / `merchant_reference`.
4. Re-check the status with `GET /transaction/{id}` before fulfilling high-value orders.
5. Never trust the browser redirect.

## Delivery & retries

Each event is delivered once immediately and logged (status, HTTP code, response excerpt) in **Developers → Webhooks**, where you can see failures. If your server was down, reconcile with `GET /transaction/{id}` or `GET /transactions?status=success`; the Paylinky team can also retry failed deliveries from the admin panel. Rotate the secret any time from the dashboard.

In the sandbox, use `POST /sandbox/simulate` to trigger `payment.completed` / `payment.failed` to your endpoint.
