A FastAPI service for securely ingesting bank transaction webhooks, verifying signatures, enforcing idempotency, and running reconciliation workflows.
This project is a lightweight showcase extracted from a much larger internal platform built at Briclinks Africa Plc (BTEL), a telecom MVNO operator using Airtel Nigeria infrastructure.
The full production system supports telecom billing, financial integrations, employee operations, and customer management. Due to NDA and client confidentiality agreements, only a subset of the features are shared here.
- Webhook Processing: Accepts bank transaction webhooks with HMAC signature verification
- Idempotency: Guaranteed via database-level unique constraints on event IDs
- Reconciliation: Matches transactions to invoices by reference
- Database: Uses SQLAlchemy 2.0 ORM with Alembic migrations
- Testing: Comprehensive test suite with Pytest
- Python 3.11+
- PostgreSQL (via local Supabase Docker stack)
- Virtual environment (venv)
python -m venv venvOn Windows:
venv\Scripts\activateOn macOS/Linux:
source venv/bin/activatepip install -e ".[dev]"Copy .env.example to .env:
cp .env.example .envEdit .env and set your values:
DATABASE_URL=postgresql+psycopg2://postgres:postgres@localhost:54322/postgres
WEBHOOK_SECRET=your-secret-key-change-this-in-productionalembic upgrade headYou can seed demo invoices using the following SQL (run in your PostgreSQL client):
INSERT INTO invoices (id, invoice_number, amount_due, currency, status)
VALUES
(gen_random_uuid(), 'INV-001', 5000.00, 'NGN', 'pending'),
(gen_random_uuid(), 'INV-002', 10000.00, 'NGN', 'pending'),
(gen_random_uuid(), 'INV-003', 7500.00, 'NGN', 'paid')
ON CONFLICT (invoice_number) DO NOTHING;Or use the provided Python script:
python scripts/seed_invoices.pyImportant: Make sure you've run the database migrations (step 4) before starting the server.
uvicorn app.main:app --reloadThe API will be available at http://localhost:8000
The application will automatically check database connectivity and table existence on startup. If tables are missing, you'll see a clear error message directing you to run migrations.
-
Start Supabase locally:
npx supabase start
-
Run migrations:
alembic upgrade head
-
Seed invoices:
python scripts/seed_invoices.py
-
Start API:
uvicorn app.main:app --reload
-
Send webhook (generate signature first - see "Generating Webhook Signatures" section):
curl.exe -X POST http://127.0.0.1:8000/webhooks/bank/transactions \ -H "Content-Type: application/json" \ -H "X-Event-Id: evt_001" \ -H "X-Signature: sha256=<your-signature>" \ -d '{"transaction_id":"txn_001","amount":5000,"currency":"NGN","occurred_at":"2026-02-27T12:00:00Z","reference":"INV-001","description":"Transfer"}'
-
Run reconciliation:
curl.exe -X POST http://127.0.0.1:8000/reconcile/run
GET /healthReturns: { "ok": true }
POST /webhooks/bank/transactionsHeaders:
X-Event-Id: Unique event identifier (required)X-Signature: HMAC SHA256 signature (required)
Body:
{
"transaction_id": "txn_001",
"amount": 5000,
"currency": "NGN",
"occurred_at": "2026-02-27T12:00:00Z",
"reference": "INV-001",
"description": "Transfer"
}Response:
{
"ok": true,
"event_id": "evt_001",
"status": "stored",
"deduped": false
}POST /reconcile/runMatches transactions to invoices by reference and returns summary.
Response:
{
"id": "uuid",
"started_at": "2026-02-27T12:00:00Z",
"finished_at": "2026-02-27T12:00:01Z",
"matched_count": 1,
"unmatched_count": 0,
"matched_transaction_ids": ["uuid"],
"unmatched_transaction_ids": []
}GET /reconcile/runs/{run_id}Returns details of a specific reconciliation run.
To generate a valid signature for testing, use this Python snippet:
import hmac
import hashlib
import json
def generate_signature(body: dict, secret: str) -> str:
body_bytes = json.dumps(body).encode("utf-8")
signature = hmac.new(
secret.encode("utf-8"),
body_bytes,
hashlib.sha256
).hexdigest()
return f"sha256={signature}"
# Example usage
payload = {
"transaction_id": "txn_001",
"amount": 5000,
"currency": "NGN",
"occurred_at": "2026-02-27T12:00:00Z",
"reference": "INV-001",
"description": "Transfer"
}
secret = "your-webhook-secret"
signature = generate_signature(payload, secret)
print(f"X-Signature: {signature}")Run the test suite:
pytestRun with verbose output:
pytest -vRun specific test file:
pytest tests/test_webhooks.py
pytest tests/test_reconciliation.pyThe complete database schema showing all tables and their relationships:
Database schema diagram showing the relationships between raw_events, transactions, invoices, reconciliation_runs, and transaction_matches tables.
Interactive API documentation available at /docs endpoint:
FastAPI's automatic interactive API documentation showing the webhook endpoint with required headers and parameters.
Example webhook requests showing idempotency in action:
Terminal output demonstrating webhook idempotency - first request returns deduped: false, second identical request returns deduped: true.
See ARCHITECTURE.md for system design including:
- Bank webhook flow
- Idempotency layer
- Reconciliation pipeline
- Database schema
- raw_events: Stores raw webhook payloads with event IDs for idempotency
- transactions: Normalized bank transaction records
- invoices: Invoice records for reconciliation
- reconciliation_runs: Tracks reconciliation execution
- transaction_matches: Stores transaction-to-invoice matching results
If you have make installed, you can use:
.PHONY: dev test db-migrate
dev:
uvicorn app.main:app --reload
test:
pytest -v
db-migrate:
alembic upgrade head
db-migrate-create:
alembic revision --autogenerate -m "$(msg)"alembic revision --autogenerate -m "Description of changes"
alembic upgrade headThis service can be deployed on:
- Render: Simple PostgreSQL + FastAPI deployment
- Railway: One-click PostgreSQL and app deployment
- AWS ECS: Containerized deployment with RDS
- Docker Compose: Local or production container orchestration
Requirements:
- PostgreSQL database (version 12+)
- Environment variables configured (see
.env.example) - Webhook secret for HMAC verification
- Python 3.11+ runtime
Example Docker Compose:
version: '3.8'
services:
api:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://user:pass@db:5432/dbname
- WEBHOOK_SECRET=${WEBHOOK_SECRET}
depends_on:
- db
db:
image: postgres:15
environment:
- POSTGRES_DB=bank_sync
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres- MVNO billing reconciliation: Match customer payments to telecom invoices
- Bank statement ingestion: Process daily bank transaction feeds
- Payment confirmation matching: Automatically link payments to outstanding invoices
- Automated telecom invoice settlement: Reconcile operator invoices with bank transfers
- Financial integrations with banking APIs: Secure webhook processing for banking partners
- Webhook signatures are verified using HMAC SHA256 with constant-time comparison
- Idempotency is enforced at the database level using UNIQUE constraints
- Environment variables are loaded from
.env(never commit.envto version control)
Error: UndefinedTable: relation "raw_events" does not exist or similar
Solution:
- Run the database migrations:
alembic upgrade head
- Verify tables were created:
alembic current
- Restart the server
The application includes startup checks that will warn you if tables are missing. If you see a startup error about missing tables, run alembic upgrade head and restart the server.
Error: Failed to connect to database or connection timeout
Solution:
- Verify your Supabase Docker stack is running:
# If using Supabase CLI supabase status - Check your
.envfile has the correctDATABASE_URL - Verify the database is accessible:
# Test connection (replace with your actual connection string) psql postgresql://postgres:postgres@localhost:54322/postgres -c "SELECT 1"
Error: Target database is not up to date or migration conflicts
Solution:
- Check current migration status:
alembic current alembic history - If migrations are out of sync, you may need to:
- Review the migration history
- Manually resolve conflicts
- Or reset and reapply (
⚠️ WARNING: This will delete all data):alembic downgrade base alembic upgrade head
Error: Migration is marked as applied but tables don't exist
Symptoms: alembic current shows a version, but startup check reports missing tables.
Solution: This happens when a migration was stamped without actually running. Fix it by:
# Downgrade to base (removes the stamp, safe if tables don't exist)
alembic downgrade base
# Then upgrade to actually create the tables
alembic upgrade headYou should see output like:
INFO [alembic.runtime.migration] Running upgrade -> 001_initial, Initial migration
Error: 401 Invalid signature
Solution:
- Verify your
WEBHOOK_SECRETin.envmatches the secret used to generate the signature - Ensure you're using the raw request body (not parsed JSON) when generating the HMAC
- Check that the signature header format is correct:
sha256=<hex_string>
Error: ModuleNotFoundError: No module named 'psycopg2'
Solution:
pip install psycopg2-binaryError: UndefinedTable: relation "raw_events" does not exist
Solution:
alembic upgrade headError: Port conflict when starting Supabase
Solution:
- Stop other Supabase projects:
npx supabase stop - Or edit
supabase/config.tomlto use different ports - Check for running containers:
docker ps
Error: 422 Unprocessable Entity
Solution: Ensure you're sending required headers:
X-Event-Id: Unique event identifierX-Signature: HMAC SHA256 signature in formatsha256=<hex>
Example:
curl -X POST http://127.0.0.1:8000/webhooks/bank/transactions \
-H "X-Event-Id: evt_001" \
-H "X-Signature: sha256=..." \
-H "Content-Type: application/json" \
-d '{...}'MIT
npx supabase start to generate your own local credentials.


