Webhooks
Webhooks allow external systems to receive real-time notifications about events happening in Visita. This guide covers webhook setup, event types, security, and troubleshooting.
Webhook URL Requirements: All webhook endpoints must use HTTPS and respond within 30 seconds.
Overview
How Webhooks Work
┌─────────────────────────────────────────────────────────────┐
│ Visita Platform │
│ Event Occurs → Webhook Triggered → HTTP POST to Your URL │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ Your Server │
│ Receive POST → Verify Signature → Process Event │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────────────────────────────────────────────────┐
│ Response to Visita │
│ 200 OK (Success) or Error (Retry) │
└─────────────────────────────────────────────────────────────┘
Webhook Flow
- Event Occurs: A user action or system event triggers a webhook
- HTTP POST: Visita sends a POST request to your webhook URL
- Signature Verification: Your server verifies the request signature
- Process Event: Your application processes the event data
- Respond: Return HTTP 200 for success, or error for retry
Setting Up Webhooks
1. Create Webhook Endpoint
Your server must expose a public HTTPS endpoint:
app/api/webhooks/visita/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifyWebhookSignature } from '@/lib/webhooks';
export async function POST(request: NextRequest) {
try {
// Get raw body
const body = await request.text();
// Verify signature
const signature = request.headers.get('x-visita-signature');
const isValid = verifyWebhookSignature(body, signature);
if (!isValid) {
return new NextResponse('Invalid signature', { status: 401 });
}
// Parse payload
const payload = JSON.parse(body);
// Process event
await processWebhookEvent(payload);
// Return success
return new NextResponse('OK', { status: 200 });
} catch (error) {
console.error('Webhook error:', error);
return new NextResponse('Error', { status: 500 });
}
}
async function processWebhookEvent(payload: any) {
const { type, data } = payload;
switch (type) {
case 'ward.signal.created':
await handleNewSignal(data);
break;
case 'business.claimed':
await handleBusinessClaimed(data);
break;
case 'project.milestone.completed':
await handleMilestoneCompleted(data);
break;
default:
console.log('Unknown event type:', type);
}
}
2. Register Webhook URL
Webhooks are configured via environment variables or admin panel:
# Webhook endpoints
WEBHOOK_WARD_EVENTS_URL=https://your-server.com/api/webhooks/visita/ward
WEBHOOK_BUSINESS_EVENTS_URL=https://your-server.com/api/webhooks/visita/business
WEBHOOK_PROJECT_EVENTS_URL=https://your-server.com/api/webhooks/visita/project
# Webhook secret for signature verification
WEBHOOK_SECRET=whsec_1234567890abcdef
Webhook Events
Ward Events
ward.signal.created
Triggered when a new signal (alert, incident, update) is created in a ward.
Payload:
{
"id": "evt_1234567890",
"type": "ward.signal.created",
"timestamp": "2025-12-30T16:00:00Z",
"data": {
"signal": {
"id": "signal-123",
"type": "safety",
"title": "Power Outage Reported",
"description": "Power outage affecting 3 blocks...",
"severity": "medium",
"ward_code": "WARD001",
"location": {
"lat": -26.2041,
"lng": 28.0473
},
"creator": {
"id": "user-456",
"name": "John Doe",
"email": "[email protected]"
},
"corroborations": 0,
"status": "active",
"created_at": "2025-12-30T16:00:00Z"
}
}
}
Use Cases:
- Notify external monitoring systems
- Trigger incident response workflows
- Update external dashboards
ward.signal.corroborated
Triggered when a signal receives corroboration from other users.
Payload:
{
"id": "evt_1234567891",
"type": "ward.signal.corroborated",
"timestamp": "2025-12-30T16:15:00Z",
"data": {
"signal": {
"id": "signal-123",
"corroborations": 5,
"status": "verified"
},
"corroborator": {
"id": "user-789",
"name": "Jane Smith"
}
}
}
ward.weather.alert
Triggered when severe weather alerts are issued for a ward.
Payload:
{
"id": "evt_1234567892",
"type": "ward.weather.alert",
"timestamp": "2025-12-30T16:00:00Z",
"data": {
"ward_code": "WARD001",
"alert": {
"type": "severe_thunderstorm",
"severity": "high",
"title": "Severe Thunderstorm Warning",
"description": "Severe thunderstorms expected...",
"effective": "2025-12-30T17:00:00Z",
"expires": "2025-12-30T20:00:00Z"
}
}
}
Business Events
business.claimed
Triggered when a business is claimed by a user.
Payload:
{
"id": "evt_1234567893",
"type": "business.claimed",
"timestamp": "2025-12-30T16:00:00Z",
"data": {
"business": {
"id": "business-123",
"name": "Maboneng Coffee",
"ward_code": "WARD001",
"previous_owner": null,
"new_owner": {
"id": "user-456",
"name": "Sarah Johnson",
"email": "[email protected]"
}
}
}
}
Use Cases:
- Sync business ownership to external CRM
- Trigger verification workflows
- Update external directories
business.verified
Triggered when a business is verified by administrators.
Payload:
{
"id": "evt_1234567894",
"type": "business.verified",
"timestamp": "2025-12-30T16:00:00Z",
"data": {
"business": {
"id": "business-123",
"name": "Maboneng Coffee",
"verified": true,
"verified_by": {
"id": "admin-001",
"name": "Admin User"
},
"verified_at": "2025-12-30T16:00:00Z"
}
}
}
Project & Governance Events
project.milestone.completed
Triggered when a community project milestone is completed.
Payload:
{
"id": "evt_1234567895",
"type": "project.milestone.completed",
"timestamp": "2025-12-30T16:00:00Z",
"data": {
"project": {
"id": "project-123",
"title": "Community Garden Initiative",
"ward_code": "WARD001"
},
"milestone": {
"id": "milestone-1",
"title": "Secure Land",
"completed_at": "2025-12-30T16:00:00Z"
},
"completed_by": {
"id": "user-456",
"name": "Sarah Johnson"
}
}
}
Use Cases:
- Notify stakeholders of progress
- Trigger celebration workflows
- Update external project management tools
governance.topic.closed
Triggered when a governance discussion topic is closed.
Payload:
{
"id": "evt_1234567896",
"type": "governance.topic.closed",
"timestamp": "2025-12-30T16:00:00Z",
"data": {
"topic": {
"id": "topic-123",
"title": "Budget Allocation for 2026",
"ward_code": "WARD001",
"consensus_score": 78.5,
"participant_count": 65,
"winning_statement": {
"id": "statement-1",
"content": "I propose we prioritize infrastructure...",
"votes": {
"agree": 45,
"disagree": 12,
"abstain": 8
}
}
}
}
}
Payment Events
payment.succeeded
Triggered when a payment is successfully processed.
Payload:
{
"id": "evt_1234567897",
"type": "payment.succeeded",
"timestamp": "2025-12-30T16:00:00Z",
"data": {
"payment": {
"id": "pay_1234567890",
"amount": 50000,
"currency": "ZAR",
"status": "succeeded",
"payment_method": "paystack",
"payer": {
"id": "user-456",
"name": "John Doe",
"email": "[email protected]"
},
"recipient": {
"type": "ward",
"id": "WARD001",
"name": "Ward 1 - Johannesburg Central"
},
"purpose": "community_project_donation",
"reference": "donation-project-123",
"created_at": "2025-12-30T16:00:00Z"
}
}
}
Use Cases:
- Sync payment data to accounting systems
- Trigger receipt generation
- Update external donation tracking
payment.failed
Triggered when a payment fails.
Payload:
{
"id": "evt_1234567898",
"type": "payment.failed",
"timestamp": "2025-12-30T16:00:00Z",
"data": {
"payment": {
"id": "pay_1234567891",
"amount": 50000,
"currency": "ZAR",
"status": "failed",
"failure_reason": "insufficient_funds",
"payer": {
"id": "user-456",
"name": "John Doe",
"email": "[email protected]"
}
}
}
}
Security
Signature Verification
All webhook requests include a signature header for verification.
Header:
X-Visita-Signature: t=1234567890,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
Verification Process:
import * as crypto from 'crypto';
export function verifyWebhookSignature(
body: string,
signatureHeader: string | null
): boolean {
if (!signatureHeader) {
return false;
}
// Parse signature header
const parts = signatureHeader.split(',');
const timestampPart = parts.find(p => p.startsWith('t='));
const signaturePart = parts.find(p => p.startsWith('v1='));
if (!timestampPart || !signaturePart) {
return false;
}
const timestamp = parseInt(timestampPart.split('=')[1]);
const signature = signaturePart.split('=')[1];
// Prevent replay attacks (within 5 minutes)
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 300) {
return false;
}
// Compute expected signature
const secret = process.env.WEBHOOK_SECRET!;
const signedPayload = `${timestamp}.${body}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Compare signatures (constant-time comparison)
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expectedSignature)
);
}
Replay Attack Prevention
The signature includes a timestamp to prevent replay attacks:
// Reject requests older than 5 minutes
if (Math.abs(now - timestamp) > 300) {
return false;
}
Retry Logic
Automatic Retries
Visita automatically retries failed webhook deliveries:
- Initial attempt: Immediately
- 1st retry: After 1 minute
- 2nd retry: After 5 minutes
- 3rd retry: After 15 minutes
- 4th retry: After 1 hour
- 5th retry: After 4 hours
Total attempts: 6 (1 initial + 5 retries)
Failure Handling
Webhooks are marked as failed if:
- Your server returns HTTP 4xx or 5xx status codes
- Your server doesn’t respond within 30 seconds
- SSL/TLS errors occur
- DNS resolution fails
Success Response
Your server must return HTTP 200 within 30 seconds:
// ✅ Success
return new NextResponse('OK', { status: 200 });
// ❌ Failure (will retry)
return new NextResponse('Error', { status: 500 });
Testing Webhooks
1. Local Testing with ngrok
# Install ngrok
npm install -g ngrok
# Start your local server
npm run dev
# Expose local server to internet
ngrok http 3000
# Use ngrok URL as webhook endpoint
WEBHOOK_WARD_EVENTS_URL=https://your-ngrok-url.ngrok.io/api/webhooks/visita/ward
2. Test Events
Send test events using the webhook testing endpoint:
curl -X POST "https://visita-intelligence.vercel.app/api/webhooks/test" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"webhook_url": "https://your-server.com/api/webhooks/visita/ward",
"event_type": "ward.signal.created",
"test_data": {
"signal": {
"id": "test-signal-123",
"type": "safety",
"title": "Test Signal",
"ward_code": "WARD001"
}
}
}'
3. Webhook Logs
View webhook delivery logs in the admin panel:
https://visita-intelligence.vercel.app/platform/admin/webhooks
Log Information:
- Event type
- Delivery timestamp
- HTTP status code
- Response time
- Error messages
- Retry count
Best Practices
1. Idempotency
Make webhook handlers idempotent to handle duplicate deliveries safely.
import { unstable_cache } from 'next/cache';
async function processWebhookEvent(payload: any) {
const eventId = payload.id;
// Check if event already processed
const cacheKey = `webhook-event-${eventId}`;
const isProcessed = await unstable_cache(
async () => false, // Check cache/database
[cacheKey],
{ revalidate: 86400 } // 24 hours
)();
if (isProcessed) {
console.log(`Event ${eventId} already processed`);
return;
}
// Process event
await handleEvent(payload);
// Mark as processed
await markEventAsProcessed(eventId);
}
2. Queue Processing
Use background queues for time-consuming webhook processing.
async function processWebhookEvent(payload: any) {
// Add to queue for background processing
await addToQueue('webhook-events', {
eventId: payload.id,
type: payload.type,
data: payload.data,
timestamp: payload.timestamp
});
// Return immediately
return;
}
3. Logging
Log all webhook events for debugging and auditing.
async function processWebhookEvent(payload: any) {
const { id, type, timestamp } = payload;
console.log(`Webhook received: ${type} (${id}) at ${timestamp}`);
try {
await handleEvent(payload);
console.log(`Webhook processed successfully: ${id}`);
} catch (error) {
console.error(`Webhook processing failed: ${id}`, error);
throw error;
}
}
4. Error Handling
Handle errors gracefully and provide meaningful responses.
async function processWebhookEvent(payload: any) {
try {
await handleEvent(payload);
} catch (error) {
console.error('Webhook error:', error);
// Return 500 to trigger retry for transient errors
if (isTransientError(error)) {
return new NextResponse('Error', { status: 500 });
}
// Return 200 for permanent errors to avoid retries
if (isPermanentError(error)) {
console.error('Permanent error, not retrying:', error);
return new NextResponse('OK', { status: 200 });
}
}
}
Common Issues
Issue: Signature Verification Fails
Symptom: Webhook requests rejected with 401 status
Solution:
// Ensure you're using the raw request body
const body = await request.text(); // ✅ Correct
const body = await request.json(); // ❌ Wrong (parses JSON)
// Verify secret matches
console.log('Expected secret:', process.env.WEBHOOK_SECRET);
// Check timestamp tolerance
const timestamp = parseInt(timestampPart.split('=')[1]);
const now = Math.floor(Date.now() / 1000);
console.log('Time difference:', Math.abs(now - timestamp));
Issue: Webhook Timeouts
Symptom: Webhook deliveries timeout after 30 seconds
Solution:
// Process webhooks asynchronously
async function POST(request: NextRequest) {
const body = await request.text();
const payload = JSON.parse(body);
// Don't await - return immediately
processWebhookAsync(payload);
return new NextResponse('OK', { status: 200 });
}
// Process in background
async function processWebhookAsync(payload: any) {
// Add to queue or process in background
await addToQueue('webhooks', payload);
}
Issue: Duplicate Events
Symptom: Same event processed multiple times
Solution:
// Implement idempotency
const processedEvents = new Set();
async function processWebhookEvent(payload: any) {
const eventId = payload.id;
if (processedEvents.has(eventId)) {
console.log(`Event ${eventId} already processed`);
return;
}
processedEvents.add(eventId);
// Process event
await handleEvent(payload);
}
Monitoring
Webhook Health Dashboard
Monitor webhook delivery in the admin panel:
https://visita-intelligence.vercel.app/platform/admin/webhooks
Metrics:
- Delivery success rate
- Average response time
- Failure count by error type
- Retry statistics
Alerting
Set up alerts for webhook failures:
// Alert on repeated failures
if (failureCount > 5) {
await sendAlert({
type: 'webhook_failure',
message: `Webhook ${webhookUrl} has failed ${failureCount} times`,
severity: 'high'
});
}
Summary
Key Takeaways:
- Always verify signatures to ensure webhook authenticity
- Implement idempotency to handle duplicate deliveries
- Use background queues for time-consuming processing
- Return HTTP 200 quickly to prevent timeouts
- Log all events for debugging and auditing
- Monitor webhook health to catch issues early