# Checkout webhooks

> Paybeta posts a signed event to your webhook_url when a payment succeeds or fails.

When a payment succeeds or fails, Paybeta sends a `POST` to the payment's `webhook_url`, or to the webhook URL saved in your API console.

## Events

| `eventType` | When |
| --- | --- |
| `transaction.successful` | The payment was paid. |
| `transaction.failed` | The payment failed. |

## Headers

| Header | Value |
| --- | --- |
| `Signature` | HMAC SHA256 of `data.reference`, lowercase hex, keyed with your live **secret key** (for test payments too). |
| `Content-Type` | `application/json` |
| `User-Agent` | `Paybeta/1.0` |
| `X-Paybeta-Version` | `2.0` |
| `X-Paybeta-Timestamp` | When it was sent, e.g. `2026-10-30 14:26:46` |

## Payload

```json
{
  "eventType": "transaction.successful",
  "type": "checkout",
  "data": {
    "reference": "ORDER-1001",
    "amount": "5000.00",
    "chargedAmount": "5000.00",
    "fee": "70.00",
    "settledAmount": "4930.00",
    "currency": "NGN",
    "transactionId": "GTW-K3J7Q2M9X4ZP8L1N5R6T0V2B3C4D",
    "transactionDate": "2026-10-30 14:26:46",
    "status": "SUCCESSFUL",
    "message": "Your transaction was successful",
    "channel": "card",
    "mode": "test",
    "description": "Pro plan · monthly",
    "customer": {
      "email": "ada@example.com",
      "name": "Ada Lovelace",
      "phone": "08012345678"
    },
    "metadata": {
      "cart_id": 9
    }
  }
}
```

## Handling it

1. **Check the signature**: compute HMAC SHA256 of `data.reference` with your live secret key and compare it with the `Signature` header, using a constant-time comparison.
2. **Reply quickly with any `2xx`.** Otherwise Paybeta retries after 1, 5, 10, 30, 60, 180 and 360 minutes.
3. **Ignore duplicates**: the same event can arrive more than once. De-duplicate on `data.transactionId` + `eventType`.
4. **Verify before you deliver**: the signature covers only `data.reference`, so confirm the amount and status with [Verify a payment](/checkout-verify-payment).

## Checking the signature

<AccordionGroup>
<Accordion title="PHP / Laravel" defaultOpen>
```php
use Illuminate\Http\Request;

public function handle(Request $request)
{
    $reference = (string) $request->input('data.reference', '');
    $expected = hash_hmac('sha256', $reference, config('services.paybeta.secret_key'));

    if (! hash_equals($expected, (string) $request->header('Signature'))) {
        abort(401);
    }

    // Verify the payment (GET /api/v1/payments/{reference}) before you deliver.
    return response()->noContent();
}
```
</Accordion>
<Accordion title="Node.js (Express)">
```javascript
const crypto = require('crypto');

app.post('/webhooks/paybeta', express.json(), (req, res) => {
  const reference = (req.body.data && req.body.data.reference) || '';
  const expected = crypto.createHmac('sha256', process.env.PAYBETA_SECRET_KEY).update(reference).digest('hex');
  const received = req.get('Signature') || '';

  if (received.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected))) {
    return res.sendStatus(401);
  }

  // Verify the payment (GET /api/v1/payments/{reference}) before you deliver.
  res.sendStatus(200);
});
```
</Accordion>
<Accordion title="Python (Flask)">
```python
import hashlib, hmac, os
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/webhooks/paybeta")
def paybeta_webhook():
    reference = (request.get_json(silent=True) or {}).get("data", {}).get("reference", "")
    expected = hmac.new(os.environ["PAYBETA_SECRET_KEY"].encode(), reference.encode(), hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, request.headers.get("Signature", "")):
        abort(401)

    # Verify the payment (GET /api/v1/payments/{reference}) before you deliver.
    return "", 200
```
</Accordion>

