Payment Gateway
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
{
"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
- Check the signature: compute HMAC SHA256 of
data.referencewith your live secret key and compare it with theSignatureheader, using a constant-time comparison. - Reply quickly with any
2xx. Otherwise Paybeta retries after 1, 5, 10, 30, 60, 180 and 360 minutes. - Ignore duplicates: the same event can arrive more than once. De-duplicate on
data.transactionId+eventType. - Verify before you deliver: the signature covers only
data.reference, so confirm the amount and status with Verify a payment.
Checking the signature
PHP / Laravel
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();
}
Node.js (Express)
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);
});
Python (Flask)
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