FastAPI project that models a small CPA / affiliate tracking workflow: register clicks, ingest partner postbacks, keep webhook processing idempotent, and expose a lightweight dashboard for manual verification.
- Thin FastAPI routes with service-layer business logic
- SQLite with transactions, foreign keys, WAL mode, and targeted indexes
- Monetary values stored as integer cents instead of floats
- Webhook processing keyed by a stable
external_ref- prefers
transaction_idfor lifecycle updates - falls back to
event_idwhen no transaction identifier is present
- prefers
- Duplicate retries are ignored, while legitimate lifecycle updates are applied in place
- Multi-currency-safe stats via explicit currency breakdowns
- Admin endpoints support optional token protection
- Webhook ingress can be protected with either a shared secret header or an HMAC body signature
- Webhook payload previews are redacted before they reach the UI
- API tests, Docker, and GitHub Actions CI included
- FastAPI
- SQLite
- Pytest
- Ruff
- Docker / Docker Compose
app/main.pywires the FastAPI surface and lifecycle.app/manual_flows.pyowns operator-driven click and manual conversion creation.app/postback_flows.pyowns webhook ingestion, duplicate handling, and lifecycle updates.app/reporting.pyowns dashboard stats and webhook-log formatting.app/service_helpers.pykeeps money parsing, status normalization, and payload helpers out of the route layer.
cpa-postback-processor/
├── .env.example
├── .github/workflows/ci.yml
├── Dockerfile
├── docker-compose.yml
├── app.py
├── app/
│ ├── __init__.py
│ ├── config.py
│ ├── db.py
│ ├── demo.py
│ ├── main.py
│ ├── schemas.py
│ ├── security.py
│ ├── manual_flows.py
│ ├── postback_flows.py
│ ├── reporting.py
│ ├── service_helpers.py
│ ├── services.py
│ └── utils.py
├── docs/
│ └── engineering-decisions.md
├── static/
│ ├── app.css
│ ├── app.js
│ └── index.html
├── tests/
│ ├── conftest.py
│ └── test_api.py
├── LICENSE
├── Makefile
├── pyproject.toml
├── requirements-dev.txt
└── requirements.txt
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt -r requirements-dev.txt
uvicorn app:app --reload --port 8001Open http://127.0.0.1:8001.
docker compose up --build| Variable | Purpose | Default |
|---|---|---|
APP_ENV |
development, test, or production |
development |
APP_DATA_DIR |
directory for SQLite files | ./data |
APP_DB_PATH |
explicit SQLite database path | ./data/cpa_tracker.db |
APP_STATIC_DIR |
static frontend assets | ./static |
DEMO_SEED_ENABLED |
seed sample rows on startup for local review | true |
DEMO_RESET_ENABLED |
enable the local sample-data reset endpoint | true in development, false in production |
CORS_ORIGINS |
comma-separated allowlist | * |
ADMIN_TOKEN |
when set, protects admin endpoints via X-Admin-Token |
empty |
WEBHOOK_SHARED_SECRET |
when set, requires X-Webhook-Secret on inbound postbacks |
empty |
WEBHOOK_HMAC_SECRET |
when set, validates a request-body HMAC signature | empty |
WEBHOOK_SIGNATURE_HEADER |
header name used for the HMAC signature | X-Signature |
WEBHOOK_SIGNATURE_PREFIX |
prefix prepended to the expected HMAC digest | sha256= |
WEBHOOK_MAX_BODY_BYTES |
max webhook request size | 65536 |
GET /api/healthGET /api/metaPOST /api/clicksPOST /api/conversionsPOST /api/webhooks/postbackGET /api/stats
GET /api/webhooksPOST /api/demo/reset(restores the local sample dataset)
Admin behavior depends on ADMIN_TOKEN:
- when
ADMIN_TOKENis set, send it inX-Admin-Token - when
ADMIN_TOKENis not set, admin routes remain available in non-production environments only
curl -X POST http://127.0.0.1:8001/api/webhooks/postback \
-H "Content-Type: application/json" \
-d '{
"source": "network-a",
"click_id": "<existing click id>",
"transaction_id": "txn-1001",
"status": "approved",
"payout": 4.25,
"revenue": 7.80,
"currency": "USD"
}'The processor prefers transaction_id as the stable external_ref. If a partner does not send one,
it falls back to event_id. Repeated retries with the same stable reference are ignored when
nothing changed. If the same reference arrives with a new status or amount, the existing conversion
is updated instead of duplicated.
- Manual click creation
- Real webhook ingestion
- Duplicate retry handling
- Status / payout updates for the same transaction
- Currency-safe analytics
- Redacted webhook previews
- Token-protected admin log access when enabled
ruff check .
pytestOr use the Makefile targets:
make lint
make testSee docs/engineering-decisions.md for the service split, idempotency trade-offs, and the local data-reset rationale.
Financial values stored as floats eventually drift. Integer cents keep arithmetic deterministic and make aggregation logic easier to review.
Partners are inconsistent: some send a stable transaction identifier, some send only an event identifier, and some send multiple events for the same transaction over time. The schema keeps a stable idempotency key separate from the displayed event identifier so lifecycle updates can be applied without duplicating conversions.
Aggregating USD and EUR into one total is wrong. The API returns payout and revenue per currency so reporting stays honest.
SQLite keeps setup friction low for a self-contained local project. For higher write volume, the next step would be PostgreSQL plus background webhook processing.
Reasonable next steps for a higher-throughput version:
- PostgreSQL instead of SQLite
- queue-backed or async webhook workers
- structured logging and metrics
- partner-specific signature verification policies
- stronger admin authentication than a single static token