VittasVittas Docs

Core Concepts → Webhooks

Webhooks

Vittas delivers real-time payment notifications to your server via webhooks — signed HTTP POST requests sent whenever a payment event occurs. Configure your endpoint in the portal under Webhooks.

Configure your endpoint

Navigate to Webhooks in the developer portal and:

  1. 1Enter your HTTPS endpoint URL in the Webhook URL field.
  2. 2Select the events you want to receive (or click "Select all").
  3. 3Click Save configuration.
  4. 4Copy the signing secret from the Signing Secret section.
  5. 5Set it as an environment variable: VITTAS_WEBHOOK_SECRET=whsec_...

Test with webhook.site

Use https://webhook.site to get a free HTTPS URL for testing. Every request is logged with headers and body in real time.

Event types

EventWhen it fires
payment.successfulCorrect amount received and confirmed by the bank.
payment.mismatchTransfer received but the amount differs from expected.
payment.expiredSession TTL elapsed with no transfer received.
payment.cancelledSession was explicitly cancelled by payer or merchant.
payment.lateTransfer received after the session expiry window.
payment.unmatchedIncoming transfer could not be linked to any session.

Webhook payload

Every webhook is a JSON body with a consistent envelope:

example payload
json
{
  "id":        "evt_01j2z9k...",
  "event":      "payment.successful",
  "createdAt": "2024-06-12T14:17:43.000Z",
  "data": {
    "id":        "3f7e2a1b-...",
    "reference": "ORDER-20240612-001",
    "status":    "successful",
    "amount":    5000000,
    "paidAmount": 5000000,
    "currency":  "NGN",
    "payerEmail": "ada@example.com",
    "payerName":  "Ada Lovelace",
    "metadata": {
      "order_id":   "ORDER-20240612-001",
      "product_id": "PROD-99"
    }
  }
}

Signature verification

Every request includes an X-Vittas-Signature header. Always verify it before processing the payload to prevent forged events.

signature header
X-Vittas-Signature: t=1718199463,v1=d4a9f3c2e7b1...
t

Unix timestamp (seconds) of when the request was sent

v1

HMAC-SHA256(secret, t + "." + raw_body)

Use the raw request body

Parse the body as raw bytes/string before JSON-decoding it. Most frameworks decode JSON early and the re-serialised string will not match the original bytes, causing signature failures.

Node.js (Express)

webhook.js
javascript
const express = require('express');
const crypto  = require('crypto');
const app     = express();

// Use raw body for webhook route
app.post('/webhooks/vittas',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const signature = req.headers['x-vittas-signature'] ?? '';
    const rawBody   = req.body; // Buffer

    if (!verifyVittasSignature(signature, rawBody, process.env.VITTAS_WEBHOOK_SECRET)) {
      return res.status(401).send('Invalid signature');
    }

    const event = JSON.parse(rawBody.toString('utf8'));

    switch (event.event) {
  case 'payment.successful':
    await fulfillOrder(event.data.reference);
    break;
  case 'payment.mismatch':
    await flagForManualReview(event.data.reference, event.data.paidAmount);
    break;
  case 'payment.expired':
    await cancelOrder(event.data.reference);
    break;
}

    // Always respond 200 quickly — do heavy work asynchronously
    res.status(200).json({ received: true });
  }
);

function verifyVittasSignature(header, body, secret) {
  try {
    const parts = header.split(',');
    const t  = parts[0].split('=')[1];
    const v1 = parts[1].split('=')[1];

    // Reject replayed requests older than 5 minutes
    if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

    const expected = crypto
      .createHmac('sha256', secret)
      .update(t + '.' + body)
      .digest('hex');

    return crypto.timingSafeEqual(
      Buffer.from(v1,       'hex'),
      Buffer.from(expected, 'hex'),
    );
  } catch {
    return false;
  }
}

Python (Flask)

webhook.py
python
import hmac
import hashlib
import time
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = os.environ['VITTAS_WEBHOOK_SECRET']

@app.route('/webhooks/vittas', methods=['POST'])
def vittas_webhook():
    signature = request.headers.get('X-Vittas-Signature', '')
    raw_body  = request.get_data()  # bytes — before json parsing

    if not verify_signature(signature, raw_body, WEBHOOK_SECRET):
        abort(401)

    event = request.get_json(force=True)

if event['event'] == 'payment.successful':
    fulfill_order(event['data']['reference'])
elif event['event'] == 'payment.mismatch':
    flag_for_review(event['data']['reference'])

    return {'received': True}, 200


def verify_signature(header: str, body: bytes, secret: str) -> bool:
    try:
        parts = dict(p.split('=', 1) for p in header.split(','))
        t, v1 = parts['t'], parts['v1']

        # Replay protection: reject if older than 5 min
        if abs(time.time() - int(t)) > 300:
            return False

        expected = hmac.new(
            secret.encode(), f"{t}.".encode() + body, hashlib.sha256
        ).hexdigest()

        return hmac.compare_digest(v1, expected)
    except Exception:
        return False

PHP

webhook.php
php
<?php
$secret  = getenv('VITTAS_WEBHOOK_SECRET');
$header  = $_SERVER['HTTP_X_VITTAS_SIGNATURE'] ?? '';
$rawBody = file_get_contents('php://input');

if (!verifyVittasSignature($header, $rawBody, $secret)) {
    http_response_code(401);
    exit('Invalid signature');
}

$event = json_decode($rawBody, true);

match ($event['event']) {
    'payment.successful' => fulfillOrder($event['data']['reference']),
    'payment.mismatch'   => flagForReview($event['data']['reference']),
    'payment.expired'    => cancelOrder($event['data']['reference']),
    default              => null,
};

http_response_code(200);
echo json_encode(['received' => true]);

function verifyVittasSignature(string $header, string $body, string $secret): bool {
    $parts = [];
    foreach (explode(',', $header) as $part) {
        [$k, $v] = explode('=', $part, 2);
        $parts[$k] = $v;
    }

    if (abs(time() - (int)$parts['t']) > 300) return false;

    $expected = hash_hmac('sha256', $parts['t'] . '.' . $body, $secret);
    return hash_equals($expected, $parts['v1']);
}

Retry policy

AttemptDelay
1st retry5 seconds
2nd retry30 seconds
3rd retry5 minutes
4th retry30 minutes
5th retry2 hours

Vittas considers a delivery successful when your endpoint returns any 2xx status code. Anything else (including timeouts after 10 seconds) triggers the retry schedule above. After all retries are exhausted the event is marked as failed — you can replay it manually from the session detail view in the portal.

Idempotency

Your handler may receive the same event more than once. Use the event id field to deduplicate: store processed IDs in a fast store (e.g. Redis) and skip duplicates.