Webhooks
Receive real-time notifications when payment events occur
Webhooks
Webhooks are HTTP callbacks that notify your application in real-time when payment events occur. Instead of constantly polling the API to check payment status, Reevit pushes events to your server as they happen.
Webhook Endpoint
Configure where Reevit sends webhook events
Signing Secret
Use this secret to verify webhook signatures
Subscribed Events
Events currently being sent to your endpoint
Understanding Webhook Architecture
Reevit handles two types of webhooks. Understanding the difference is crucial:
Inbound Webhooks (PSP → Reevit)
These are webhooks from payment providers (Paystack, Flutterwave, M-Pesa, etc.) to Reevit.
Key Points:
- Reevit handles these automatically - you don't need to do anything
- Configure the Reevit webhook URL in each PSP's dashboard
- Reevit processes provider events and updates payment status
PSP Webhook URLs:
| Provider | Webhook URL to configure in PSP Dashboard |
|---|---|
| Paystack | https://api.reevit.com/webhooks/paystack |
| Flutterwave | https://api.reevit.com/webhooks/flutterwave |
| Hubtel | https://api.reevit.com/webhooks/hubtel |
| M-Pesa | https://api.reevit.com/webhooks/mpesa |
| Stripe | https://api.reevit.com/webhooks/stripe |
You configure these URLs in the payment provider's dashboard (e.g., Paystack Dashboard), NOT in Reevit.
Required Metadata for Webhook Routing
Critical: For inbound webhooks to route correctly, your payments must include specific metadata fields when created. Without these fields, webhooks will fail with a 400 Bad Request error.
When creating payments (via SDK or API), you must include the following metadata fields:
| Field | Required | Description |
|---|---|---|
org_id | ✅ Yes | Your organization ID (from Reevit Dashboard) |
connection_id | ✅ Yes | The connection ID used for the payment |
payment_id | ✅ Yes | Your internal payment/order ID (Required for Stripe & routing) |
customer_email | Optional | Customer email for receipts |
Example: SDK Usage
<ReevitCheckout
publicKey="pfk_live_xxx"
amount={5000}
currency="GHS"
email="customer@example.com"
metadata={{
org_id: "org_abc123", // Required for webhook routing
connection_id: "conn_xyz789", // Required for webhook routing
payment_id: "order_12345", // Your internal reference
customer_email: "customer@example.com"
}}
// ...
/>Example: API Usage
curl -X POST https://api.reevit.com/v1/payments/intents \
-H "X-Reevit-Key: pfk_live_xxx" \
-H "X-Org-Id: org_abc123" \
-d '{
"amount": 5000,
"currency": "GHS",
"method": "card",
"country": "GH",
"metadata": {
"org_id": "org_abc123",
"connection_id": "conn_xyz789",
"payment_id": "order_12345"
}
}'When using Payment Links, Reevit automatically populates these fields from the payment link configuration. You don't need to manually set them.
Outbound Webhooks (Reevit → Your App)
These are webhooks from Reevit to your application.
Key Points:
- You must create a webhook handler in your application
- Configure your webhook URL in the Reevit Dashboard
- Reevit sends you normalized events (same format regardless of PSP)
- You verify signatures using your organization's signing secret
This is the webhook you configure in Reevit Dashboard > Developers > Webhooks.
Why Webhooks Matter
The Problem with Polling
Without webhooks, you'd need to:
- Poll the API every few seconds to check payment status
- Waste server resources on unnecessary requests
- Experience delays between payment completion and order fulfillment
- Miss events if your polling interval is too long
Example: Customer completes payment at 10:00:00, but you only poll at 10:00:30 → 30-second delay before order fulfillment.
The Webhook Solution
With webhooks:
- Instant notifications: Know immediately when payments succeed or fail
- Efficient: No wasted API calls
- Reliable: Events are delivered even if you're not actively checking
- Scalable: Works with thousands of payments per minute
Result: Faster order fulfillment, better customer experience, lower server costs.
How Outbound Webhooks Work
The Flow
1. Payment Event Occurs → 2. Reevit Sends Webhook → 3. Your Server Processes → 4. You Respond- Event Occurs: Payment succeeds, fails, or changes status
- Reevit Sends: POST request to your webhook URL with event data
- Your Server Processes: Verify signature, update database, fulfill order
- You Respond: Return 2xx status code to acknowledge receipt
Key Concepts
- Webhook URL: Your server endpoint that receives events
- Signing Secret: Unique secret per organization for signature verification
- Event Types: Different events for different payment states
- Signatures: Cryptographic signatures to verify events are from Reevit
- Idempotency: Events include IDs to prevent duplicate processing
- Retries: Failed deliveries are automatically retried
Setting Up Webhooks
Via Dashboard (Recommended)
- Go to Settings → Webhooks in your Reevit dashboard
- Enter your webhook endpoint URL (must be HTTPS)
- Select which events you want to receive
- Copy the webhook signing secret
- Save configuration
Benefits:
- Visual interface
- Easy to update
- See delivery status
Via API
curl -X POST https://api.reevit.com/v1/webhooks/config \
-H "X-Reevit-Key: pfk_live_xxx" \
-H "X-Org-Id: org_123" \
-H "Idempotency-Key: webhook-config-001" \
-d '{
"url": "https://yourapp.com/webhooks/reevit",
"events": [
"payment.succeeded",
"payment.failed",
"payment.refunded",
"subscription.renewed"
],
"enabled": true
}'Webhook URL Requirements
- HTTPS only: HTTP endpoints are not supported for security
- Publicly accessible: Must be reachable from the internet
- Fast response: Return 2xx within 30 seconds
- Idempotent: Handle duplicate events gracefully
Get Configuration
curl https://api.reevit.com/v1/webhooks/config \
-H "X-Reevit-Key: pfk_live_xxx" \
-H "X-Org-Id: org_123"Response:
{
"url": "https://yourapp.com/webhooks/reevit",
"events": ["payment.succeeded", "payment.failed"],
"enabled": true,
"signing_secret": "whsec_xxx",
"created_at": "2025-02-10T10:00:00Z"
}Event Types
Reevit sends different events for different scenarios. Subscribe only to events you need.
Payment Events
| Event | When It Fires | Use Case |
|---|---|---|
payment.created | Payment intent created | Log payment attempts |
payment.succeeded | Payment completed successfully | Fulfill order, send confirmation |
payment.failed | Payment failed | Notify customer, allow retry |
payment.canceled | Payment canceled | Release inventory, update order |
payment.refunded | Refund completed | Update order status, notify customer |
Subscription Events
| Event | When It Fires | Use Case |
|---|---|---|
subscription.created | New subscription created | Activate subscription, send welcome email |
subscription.updated | Subscription modified | Update subscription status |
subscription.canceled | Subscription canceled | Deactivate access, send cancellation email |
subscription.paused | Subscription paused | Suspend access temporarily |
subscription.resumed | Subscription resumed | Reactivate access |
Invoice Events
| Event | When It Fires | Use Case |
|---|---|---|
invoice.created | Invoice generated | Notify customer of upcoming charge |
invoice.paid | Invoice payment succeeded | Extend subscription, grant access |
invoice.failed | Invoice payment failed | Notify customer, retry payment |
invoice.canceled | Invoice canceled | Update subscription status |
Webhook Payload
Every webhook includes a consistent payload structure:
{
"id": "evt_abc123",
"type": "payment.succeeded",
"org_id": "org_123",
"created_at": "2025-02-10T10:05:00Z",
"data": {
"id": "pay_xyz789",
"amount": 5000,
"currency": "GHS",
"status": "succeeded",
"provider": "paystack",
"customer_id": "cust_456",
"metadata": {
"order_id": "12345"
}
}
}Payload Fields
id: Unique event identifier (use for idempotency)type: Event type (e.g.,payment.succeeded)org_id: Your organization IDcreated_at: When the event occurred (ISO 8601)data: Event-specific data (varies by event type)
Using Event Data
Example: Fulfill Order on Payment Success
if (event.type === 'payment.succeeded') {
const payment = event.data;
const orderId = payment.metadata.order_id;
// Fulfill order
await fulfillOrder(orderId);
// Send confirmation email
await sendConfirmationEmail(payment.customer_id);
// Update analytics
await trackPurchase(payment);
}🔒 Webhook Security
Webhooks deliver sensitive payment data to your application. Follow these security practices to protect your integration.
Signature Verification (Critical)
Never process webhooks without signature verification. This is the most critical security step.
Reevit signs each webhook with HMAC-SHA256. Your server must verify this signature before processing any event.
What happens without verification?
- Attackers could send fake "payment succeeded" events
- Orders could be fulfilled without actual payment
- Financial losses and fraud
Anti-Replay Protection
Reebit's webhooks include a timestamp field to prevent replay attacks:
const WEBHOOK_TOLERANCE = 300; // 5 minutes
function verifyWebhook(payload: string, signature: string, secret: string): boolean {
// 1. Verify signature
if (!verifySignature(payload, signature, secret)) {
return false;
}
// 2. Check timestamp to prevent replay attacks
const parsed = JSON.parse(payload);
const timestamp = parsed.timestamp || 0;
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > WEBHOOK_TOLERANCE) {
console.log('Webhook timestamp outside tolerance window');
return false;
}
return true;
}Best Practices
-
Always use raw request body for verification
// ❌ Wrong - Express parses body, breaking signature app.post('/webhooks', (req, res) => { verifySignature(req.body, req.headers['x-reevit-signature'], secret); }); // ✅ Correct - use raw body app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => { const signature = req.headers['x-reevit-signature']; verifySignature(req.body.toString(), signature, secret); }); -
Respond quickly (under 5 seconds)
- Return 2xx immediately after verification
- Process events asynchronously
- Reevit will retry failed deliveries
-
Implement idempotent handlers
async function handleWebhook(event) { const eventId = event.id; // Check if already processed const processed = await db.webhookEvents.findUnique({ where: { id: eventId } }); if (processed) { return res.send({ status: 'already_processed' }); } // Mark as processing await db.webhookEvents.create({ id: eventId, status: 'processing' }); try { await processEvent(event); await db.webhookEvents.update({ where: { id: eventId }, data: { status: 'completed' } }); } catch (err) { await db.webhookEvents.update({ where: { id: eventId }, data: { status: 'failed' } }); throw err; } } -
Use HTTPS endpoints
- Webhook URLs must use HTTPS
- Reevit rejects HTTP endpoints
- Use valid TLS certificates (not self-signed)
-
Limit webhook IP ranges
- Reevit webhooks come from Cloudflare IPs
- Configure firewall to allow only these ranges
Security Checklist
Review this checklist before going live:
- Signature verification implemented
- Anti-replay protection (timestamp check)
- Idempotent event handlers
- HTTPS endpoint with valid certificate
- Fast response time (< 5 seconds)
- Proper error logging without sensitive data
- Webhook secret stored securely (env vars, vault)
- Test endpoint configured in Reevit Dashboard
Verifying Webhooks
Always verify webhook signatures. This ensures events are actually from Reevit and haven't been tampered with.
Why Verification Matters
Without verification, attackers could:
- Send fake payment success events
- Trigger order fulfillment without payment
- Cause financial losses
- Compromise your system
How Verification Works
Reevit signs each webhook using HMAC-SHA256 with your organization's signing secret:
- Header:
X-Reevit-Signature: sha256=<hex-signature> - Signature:
HMAC-SHA256(request_body, signing_secret) - Verification: Compare using constant-time comparison
Getting Your Signing Secret
Your signing secret is automatically generated when you first configure a webhook endpoint:
- Go to Reevit Dashboard > Developers > Webhooks
- Add your webhook endpoint URL
- Click the copy button next to "Signing Secret"
- Add to your environment:
REEVIT_WEBHOOK_SECRET=whsec_xxx...
The signing secret is unique per organization and starts with whsec_. It's generated once and persists across webhook URL updates.
Next.js App Router Example (Recommended)
// app/api/webhooks/reevit/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
function verifySignature(payload: string, signature: string, secret: string): boolean {
if (!signature.startsWith("sha256=")) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
const received = signature.slice(7); // Remove "sha256=" prefix
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
}
export async function POST(request: NextRequest) {
const rawBody = await request.text();
const signature = request.headers.get("x-reevit-signature") || "";
const secret = process.env.REEVIT_WEBHOOK_SECRET!;
// Verify signature
if (!verifySignature(rawBody, signature, secret)) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
const event = JSON.parse(rawBody);
// Handle events
switch (event.type) {
case "reevit.webhook.test":
console.log("Test webhook received");
break;
case "payment.succeeded":
await handlePaymentSucceeded(event.data);
break;
case "payment.failed":
await handlePaymentFailed(event.data);
break;
}
return NextResponse.json({ received: true });
}
async function handlePaymentSucceeded(data: any) {
const orderId = data.metadata?.order_id;
// Update order status, send confirmation email, etc.
console.log(`Payment ${data.id} succeeded for order ${orderId}`);
}
async function handlePaymentFailed(data: any) {
// Notify customer, allow retry, etc.
console.log(`Payment ${data.id} failed`);
}Express.js Example
const crypto = require('crypto');
const express = require('express');
function verifySignature(payload, signature, secret) {
if (!signature.startsWith('sha256=')) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
const received = signature.slice(7);
if (received.length !== expected.length) return false;
return crypto.timingSafeEqual(
Buffer.from(received),
Buffer.from(expected)
);
}
// IMPORTANT: Use raw body for signature verification
app.post('/webhooks/reevit', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-reevit-signature'];
const payload = req.body.toString();
const secret = process.env.REEVIT_WEBHOOK_SECRET;
if (!verifySignature(payload, signature, secret)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(payload);
switch (event.type) {
case 'payment.succeeded':
// Fulfill order
break;
case 'payment.failed':
// Notify customer
break;
}
res.status(200).json({ received: true });
});Go Example
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"os"
"strings"
)
func verifySignature(payload []byte, signature, secret string) bool {
if !strings.HasPrefix(signature, "sha256=") {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(payload)
expected := hex.EncodeToString(mac.Sum(nil))
received := signature[7:] // Remove "sha256=" prefix
return hmac.Equal([]byte(received), []byte(expected))
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
signature := r.Header.Get("X-Reevit-Signature")
secret := os.Getenv("REEVIT_WEBHOOK_SECRET")
if !verifySignature(body, signature, secret) {
http.Error(w, "Invalid signature", http.StatusUnauthorized)
return
}
var event map[string]interface{}
json.Unmarshal(body, &event)
// Handle event based on type
switch event["type"] {
case "payment.succeeded":
// Fulfill order
case "payment.failed":
// Notify customer
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}Python (Flask) Example
import hmac
import hashlib
import os
from flask import Flask, request, jsonify
app = Flask(__name__)
def verify_signature(payload: bytes, signature: str, secret: str) -> bool:
if not signature.startswith('sha256='):
return False
expected = hmac.new(
secret.encode('utf-8'),
payload,
hashlib.sha256
).hexdigest()
received = signature[7:] # Remove "sha256=" prefix
return hmac.compare_digest(received, expected)
@app.route('/webhooks/reevit', methods=['POST'])
def webhook():
payload = request.get_data()
signature = request.headers.get('X-Reevit-Signature', '')
secret = os.environ['REEVIT_WEBHOOK_SECRET']
if not verify_signature(payload, signature, secret):
return jsonify({'error': 'Invalid signature'}), 401
event = request.get_json()
if event['type'] == 'payment.succeeded':
# Fulfill order
pass
elif event['type'] == 'payment.failed':
# Notify customer
pass
return jsonify({'received': True})PHP Example
<?php
function verifySignature(string $payload, string $signature, string $secret): bool {
if (strpos($signature, 'sha256=') !== 0) {
return false;
}
$expected = hash_hmac('sha256', $payload, $secret);
$received = substr($signature, 7); // Remove "sha256=" prefix
return hash_equals($expected, $received);
}
// Webhook handler
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_REEVIT_SIGNATURE'] ?? '';
$secret = getenv('REEVIT_WEBHOOK_SECRET');
if (!verifySignature($payload, $signature, $secret)) {
http_response_code(401);
echo json_encode(['error' => 'Invalid signature']);
exit;
}
$event = json_decode($payload, true);
switch ($event['type']) {
case 'payment.succeeded':
// Fulfill order
break;
case 'payment.failed':
// Notify customer
break;
}
http_response_code(200);
echo json_encode(['received' => true]);Processing Webhooks
Best Practices
- Respond Quickly: Return 2xx within 30 seconds
- Process Async: Queue events for background processing
- Handle Duplicates: Use event ID for idempotency
- Verify Signatures: Always verify webhook authenticity
- Log Events: Log all events for debugging and audit
Idempotency
Events include unique IDs. Use these to prevent duplicate processing:
// Check if event already processed
const existingEvent = await db.events.findOne({ id: event.id });
if (existingEvent) {
return res.status(200).send('Already processed');
}
// Process event
await processEvent(event);
// Store event ID
await db.events.insert({ id: event.id, processed_at: new Date() });Error Handling
Return 2xx for success: Even if processing fails internally, return 2xx to acknowledge receipt. Process failures in background.
Return 4xx/5xx for retry: Only return error codes if you want Reevit to retry:
- 4xx: Client error (won't retry)
- 5xx: Server error (will retry)
Example:
app.post('/webhooks/reevit', async (req, res) => {
try {
// Verify signature
if (!verifyWebhook(req.body, signature, secret)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
// Queue for processing (don't block webhook response)
await queue.add('process-webhook', event);
// Return success immediately
res.status(200).send('OK');
} catch (error) {
// Log error but still return 200
console.error('Webhook error:', error);
res.status(200).send('OK');
}
});Testing Webhooks
Test Events
Send a test event to verify your endpoint:
curl -X POST https://api.reevit.com/v1/webhooks/test \
-H "X-Reevit-Key: pfk_live_xxx" \
-H "X-Org-Id: org_123"Use Cases:
- Initial setup: Verify endpoint is working
- After changes: Test after updating webhook URL
- Debugging: Test signature verification
Local Development
For local development, use tools like:
- ngrok: Expose local server to internet
- webhook.site: Temporary webhook URLs for testing
- Stripe CLI: Forward webhooks to local server
Example with ngrok:
# Start ngrok
ngrok http 3000
# Use ngrok URL in webhook config
# https://abc123.ngrok.io/webhooks/reevitViewing Webhook Events
List Events
curl "https://api.reevit.com/v1/webhooks/events?limit=20" \
-H "X-Reevit-Key: pfk_live_xxx" \
-H "X-Org-Id: org_123"Use Cases:
- Debugging: See what events were sent
- Audit: Review event history
- Monitoring: Check event delivery
Get Single Event
curl https://api.reevit.com/v1/webhooks/events/evt_abc123 \
-H "X-Reevit-Key: pfk_live_xxx" \
-H "X-Org-Id: org_123"Use Cases:
- Investigation: Review specific event details
- Replay: Get event data for replaying
Replaying Events
Re-send a webhook event if delivery failed or you need to reprocess:
curl -X POST https://api.reevit.com/v1/webhooks/events/evt_abc123/replay \
-H "X-Reevit-Key: pfk_live_xxx" \
-H "X-Org-Id: org_123"Use Cases:
- Failed delivery: Retry after fixing endpoint
- Reprocessing: Reprocess event after bug fix
- Testing: Test event processing logic
Delivery Status
View Delivery Attempts
curl https://api.reevit.com/v1/webhooks/outbound \
-H "X-Reevit-Key: pfk_live_xxx" \
-H "X-Org-Id: org_123"Response:
{
"deliveries": [
{
"id": "del_123",
"event_id": "evt_abc123",
"url": "https://yourapp.com/webhooks/reevit",
"status": "delivered",
"response_code": 200,
"attempts": 1,
"delivered_at": "2025-02-10T10:05:01Z"
},
{
"id": "del_124",
"event_id": "evt_def456",
"url": "https://yourapp.com/webhooks/reevit",
"status": "failed",
"response_code": 500,
"attempts": 5,
"last_attempt_at": "2025-02-10T10:10:00Z"
}
]
}Delivery Statuses
delivered: Successfully delivered and acknowledgedpending: Queued for deliveryfailed: Failed after all retry attemptsretrying: Currently retrying after failure
Retry Behavior
Reevit automatically retries failed webhook deliveries with exponential backoff:
| Attempt | Delay | Total Time |
|---|---|---|
| 1 | Immediate | 0s |
| 2 | 1 minute | 1m |
| 3 | 5 minutes | 6m |
| 4 | 30 minutes | 36m |
| 5 | 2 hours | 2h 36m |
After 5 failed attempts, the event is marked as failed and won't be retried automatically.
Why Retries Matter
- Network issues: Temporary network problems resolve themselves
- Server restarts: Your server may be restarting
- Rate limiting: Temporary rate limits may clear
- Transient errors: Some errors are temporary
Best Practice: Make your webhook endpoint idempotent so retries are safe.
Security Best Practices
- Always Verify Signatures: Never process unverified webhooks
- Use HTTPS: Only accept webhooks over HTTPS
- Validate Event Data: Don't trust event data blindly
- Rate Limiting: Implement rate limiting on webhook endpoint
- Monitor Failures: Alert on repeated webhook failures
- Keep Secrets Secure: Never expose webhook secrets
- Use Idempotency: Handle duplicate events gracefully
Common Patterns
Order Fulfillment
if (event.type === 'payment.succeeded') {
const orderId = event.data.metadata.order_id;
await fulfillOrder(orderId);
await sendConfirmationEmail(event.data.customer_id);
}Subscription Management
if (event.type === 'subscription.renewed') {
const subscription = event.data;
await extendAccess(subscription.customer_id);
await sendRenewalConfirmation(subscription);
}Refund Processing
if (event.type === 'payment.refunded') {
const refund = event.data;
await updateOrderStatus(refund.metadata.order_id, 'refunded');
await notifyCustomer(refund.customer_id, 'refund_processed');
}Failed Payment Handling
if (event.type === 'payment.failed') {
const payment = event.data;
await notifyCustomer(payment.customer_id, 'payment_failed');
await allowRetry(payment.id);
}Troubleshooting
Webhooks Not Received
- Check URL: Verify webhook URL is correct and accessible
- Check HTTPS: Ensure URL uses HTTPS
- Check Firewall: Ensure firewall allows Reevit IPs
- Check Logs: Review delivery logs for errors
- Test Endpoint: Use test endpoint to verify connectivity
Signature Verification Failing
- Check Secret: Verify webhook secret is correct
- Check Payload: Ensure you're using raw request body
- Check Encoding: Ensure proper string encoding
- Check Headers: Verify signature header name
Duplicate Events
- Use Event IDs: Check event ID before processing
- Implement Idempotency: Make handlers idempotent
- Check Retries: Verify retry logic isn't causing duplicates