Skip to main content

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

  1. Event Occurs: A user action or system event triggers a webhook
  2. HTTP POST: Visita sends a POST request to your webhook URL
  3. Signature Verification: Your server verifies the request signature
  4. Process Event: Your application processes the event data
  5. 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:
.env.local
# 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:
lib/webhooks.ts
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

Event Types

Security

Retry Logic

Best Practices

Key Takeaways:
  1. Always verify signatures to ensure webhook authenticity
  2. Implement idempotency to handle duplicate deliveries
  3. Use background queues for time-consuming processing
  4. Return HTTP 200 quickly to prevent timeouts
  5. Log all events for debugging and auditing
  6. Monitor webhook health to catch issues early