VTU API
Get API keys
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

  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.

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