- verifies the signature with the open-source
svixlibrary, which also rejects timestamps more than five minutes off; - parses the raw body itself, because
verifyreturns nothing; - skips a
webhook-idit has already handled; - handles the event, or queues it, and only then records the id;
- answers
204, or500if handling failed so the event is retried.
FASTCAC_WEBHOOK_SECRET to your endpoint’s whsec_… secret.
Express
Mount it withexpress.raw so the body stays unparsed. npm install express svix
server.js
import express from "express";
import { Webhook } from "svix";
const wh = new Webhook(process.env.FASTCAC_WEBHOOK_SECRET);
const handled = new Set(); // use your database in production, with a unique index on the id
const app = express();
app.post("/fastcac/webhooks", express.raw({ type: "application/json" }), async (req, res) => {
try {
wh.verify(req.body, req.headers); // throws if the signature or timestamp is wrong
} catch {
return res.status(400).send("invalid signature");
}
const event = JSON.parse(req.body);
const id = req.headers["webhook-id"];
if (handled.has(id)) return res.sendStatus(204); // already handled
try {
await handle(event); // keep it quick, or push to your job queue here
} catch (err) {
console.error(err);
return res.sendStatus(500); // not recorded, so the retry runs it again
}
handled.add(id);
res.sendStatus(204);
});
async function handle(event) {
const { data } = event;
switch (event.type) {
case "registration.approved":
// Fetch the registration for its registration_number; wait for certificate_ready for PDFs.
break;
case "registration.queried":
// Fetch the registration, show the customer its `query`, then resubmit.
break;
case "registration.rejected":
// CAC refused it. Tell the customer.
break;
case "registration.verification_failed":
// Fetch the registration, fix the fields in `failure.fields`, resubmit at no charge.
break;
case "document.rejected":
// Ask for a new upload; data.reason says why.
break;
case "balance.low":
// data.available fell below data.low_balance_threshold. Top up.
break;
case "balance.funded":
// data.amount was credited; data.available is the new balance.
break;
default:
// Ignore types you don't handle.
}
}
app.listen(3000);
Laravel
Add the route inroutes/api.php (run php artisan install:api first on Laravel 11+), which skips
CSRF checks. composer require svix/svix
app/Http/Controllers/FastcacWebhookController.php
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Svix\Webhook;
class FastcacWebhookController extends Controller
{
public function __invoke(Request $request)
{
$headers = [
'webhook-id' => $request->header('webhook-id'),
'webhook-timestamp' => $request->header('webhook-timestamp'),
'webhook-signature' => $request->header('webhook-signature'),
];
try {
// Throws if the signature or timestamp is wrong; returns nothing.
// config/services.php: 'fastcac' => ['webhook_secret' => env('FASTCAC_WEBHOOK_SECRET')]
$secret = config('services.fastcac.webhook_secret');
(new Webhook($secret))->verify($request->getContent(), $headers);
} catch (\Exception $e) {
return response('invalid signature', 400);
}
$event = json_decode($request->getContent(), true);
// Use a shared cache store (Redis, database).
$key = 'fastcac-webhook:'.$headers['webhook-id'];
if (Cache::has($key)) {
return response()->noContent(); // already handled
}
$this->handle($event); // keep it quick, or dispatch a job here
// An exception above answers 500 and isn't recorded, so the retry runs it again.
Cache::put($key, true, now()->addDays(3));
return response()->noContent();
}
private function handle(array $event): void
{
$data = $event['data'];
switch ($event['type']) {
case 'registration.approved':
// Fetch the registration for its registration_number; wait for certificate_ready for PDFs.
break;
case 'registration.queried':
// Fetch the registration, show the customer its `query`, then resubmit.
break;
case 'registration.rejected':
// CAC refused it. Tell the customer.
break;
case 'registration.verification_failed':
// Fetch the registration, fix the fields in `failure.fields`, resubmit at no charge.
break;
case 'document.rejected':
// Ask for a new upload; $data['reason'] says why.
break;
case 'balance.low':
// $data['available'] fell below $data['low_balance_threshold']. Top up.
break;
case 'balance.funded':
// $data['amount'] was credited; $data['available'] is the new balance.
break;
default:
// Ignore types you don't handle.
}
}
}
routes/api.php
use App\Http\Controllers\FastcacWebhookController;
Route::post('/fastcac/webhooks', FastcacWebhookController::class);
Django
A plain view, exempt from CSRF.pip install svix
webhooks/views.py
import json
import os
from django.core.cache import cache
from django.http import HttpResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from svix.webhooks import Webhook, WebhookVerificationError
wh = Webhook(os.environ["FASTCAC_WEBHOOK_SECRET"])
@csrf_exempt
@require_POST
def fastcac_webhook(request):
try:
wh.verify(request.body, dict(request.headers)) # raises if the signature or timestamp is wrong
except WebhookVerificationError:
return HttpResponse("invalid signature", status=400)
event = json.loads(request.body)
# Use a shared cache (Redis, database).
key = f"fastcac-webhook:{request.headers['webhook-id']}"
if cache.get(key):
return HttpResponse(status=204) # already handled
handle(event) # keep it quick, or hand it to Celery or RQ here
# An exception above answers 500 and isn't recorded, so the retry runs it again.
cache.set(key, True, timeout=3 * 24 * 3600)
return HttpResponse(status=204)
def handle(event):
data = event["data"]
match event["type"]:
case "registration.approved":
pass # Fetch the registration for its registration_number; wait for certificate_ready for PDFs.
case "registration.queried":
pass # Fetch the registration, show the customer its `query`, then resubmit.
case "registration.rejected":
pass # CAC refused it. Tell the customer.
case "registration.verification_failed":
pass # Fetch the registration, fix the fields in `failure.fields`, resubmit at no charge.
case "document.rejected":
pass # Ask for a new upload; data["reason"] says why.
case "balance.low":
pass # data["available"] fell below data["low_balance_threshold"]. Top up.
case "balance.funded":
pass # data["amount"] was credited; data["available"] is the new balance.
case _:
pass # Ignore types you don't handle.
urls.py
from django.urls import path
from webhooks.views import fastcac_webhook
urlpatterns = [path("fastcac/webhooks", fastcac_webhook)]