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:
- 1Enter your HTTPS endpoint URL in the Webhook URL field.
- 2Select the events you want to receive (or click "Select all").
- 3Click Save configuration.
- 4Copy the signing secret from the Signing Secret section.
- 5Set it as an environment variable: VITTAS_WEBHOOK_SECRET=whsec_...
Test with webhook.site
Usehttps://webhook.site to get a free HTTPS URL for testing. Every request is logged with headers and body in real time.Event types
| Event | When it fires |
|---|---|
| payment.successful | Correct amount received and confirmed by the bank. |
| payment.mismatch | Transfer received but the amount differs from expected. |
| payment.expired | Session TTL elapsed with no transfer received. |
| payment.cancelled | Session was explicitly cancelled by payer or merchant. |
| payment.late | Transfer received after the session expiry window. |
| payment.unmatched | Incoming transfer could not be linked to any session. |
Webhook payload
Every webhook is a JSON body with a consistent envelope:
{ "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.
X-Vittas-Signature: t=1718199463,v1=d4a9f3c2e7b1...
Unix timestamp (seconds) of when the request was sent
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)
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)
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
<?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
| Attempt | Delay |
|---|---|
| 1st retry | 5 seconds |
| 2nd retry | 30 seconds |
| 3rd retry | 5 minutes |
| 4th retry | 30 minutes |
| 5th retry | 2 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 eventid field to deduplicate: store processed IDs in a fast store (e.g. Redis) and skip duplicates.