Outbound Webhooks
Platform-to-operator push notifications -- payment events, retry logic, signing, and delivery guarantees
When is this needed?
This is only needed if the operator has their own backend that must react to payment events independently of the platform. For example: the operator has their own CRM, their own bonus system, their own game server.
If the platform fully manages the game flow (game, bonuses, balance -- everything inside the platform), then there is no point for the operator to receive these pushes -- they already work through the platform.
Conclusion for this architecture
If operators are casino operators with their own product (their own games, CRM, frontend), and the platform only handles payments -- then Outbound Webhooks are needed.
If the platform itself is the product (operators work inside it, without their own backend) -- then this module is either not needed at all, or can be deferred to Phase 5-6 when there is a real request from a specific operator.
Why It Is Needed
2xx to acknowledge receipt -- if not, the platform retries on an exponential backoff schedule.Real-time operator awareness
Operators need to know immediately when a player deposits -- to unlock game access, apply a bonus, or update player account state. Polling the platform API every few seconds at scale is wasteful and introduces latency. Push events eliminate both problems.
Operator-side automation
Operators run their own backend logic triggered by payment events -- updating CRM records, triggering email notifications to players, adjusting loyalty points, feeding analytics pipelines. Without webhooks, operators must build polling infrastructure. With webhooks, the platform drives the event.
Decoupled architecture
Operators don't need API access into the platform's internal state. The webhook payload contains everything the operator needs for that event. This limits the operator's surface area and keeps platform internals private.
Compliance event trail
Every webhook delivery -- attempt, response code, timestamp -- is stored in webhook_deliveries. This gives regulators and operators a verifiable record of when and what the platform communicated. Critical for AML reporting disputes and chargeback evidence.
Event Catalog
2xx on re-delivery of the same event without reprocessing. Events marked deposit.initiatedPlayer submits deposit form -- PSP call not yet made
data: transaction_id, player_id, amount, currency, method, psp_id
deposit.processingPSP accepted the request -- awaiting confirmation (e.g. crypto awaiting blockchain)
data: transaction_id, psp_reference, estimated_confirmation_time
deposit.completedPSP confirmed payment -- balance credited to Wallet
data: transaction_id, player_id, settlement_amount, settlement_currency, display_amount, display_currency, fx_rate, wallet_balance_after
deposit.failedPSP declined or transaction expired -- no balance change
data: transaction_id, player_id, failure_reason, failure_code, psp_id
deposit.cascade_fallbackFirst PSP failed -- Cascade Manager is retrying with next PSP
data: transaction_id, failed_psp_id, next_psp_id, attempt_number
withdrawal.requestedPlayer submitted withdrawal request -- pending approval or auto-processing
data: transaction_id, player_id, amount, currency, method, kyc_level
withdrawal.approvedOps or auto-approval rule approved the withdrawal -- PSP call imminent
data: transaction_id, player_id, approved_by (user_id or "auto"), approved_at
withdrawal.rejectedOps rejected the withdrawal manually -- balance hold released back to player
data: transaction_id, player_id, rejection_reason, rejected_by, wallet_balance_after
withdrawal.completedPSP confirmed funds sent to player -- balance deducted from Wallet
data: transaction_id, player_id, settlement_amount, psp_reference, wallet_balance_after
withdrawal.failedPSP failed to send funds -- balance hold released back to player
data: transaction_id, player_id, failure_reason, failure_code, wallet_balance_after
chargeback.openedPSP notified platform of a new dispute on a transaction
data: chargeback_id, transaction_id, player_id, amount, reason_code, evidence_deadline
chargeback.wonCard network ruled in platform's favor -- disputed funds returned
data: chargeback_id, transaction_id, player_id, amount, resolved_at
chargeback.lostCard network ruled in cardholder's favor -- funds permanently reversed
data: chargeback_id, transaction_id, player_id, amount, resolved_at
kyc.level_upgradedPlayer completed a verification step -- KYC level increased
data: player_id, previous_level, new_level, verified_at, documents_submitted
kyc.rejectedKYC provider rejected submitted documents
data: player_id, rejection_reason, rejected_documents, can_resubmit
kyc.documents_expiredPlayer's identity documents have passed their expiry date -- re-verification required
data: player_id, expired_document_type, expiry_date, grace_period_ends_at
player.self_excludedPlayer activated self-exclusion -- all payment processing blocked
data: player_id, exclusion_type (temporary|permanent), exclusion_ends_at (null if permanent)
player.limit_reachedPlayer hit a configured deposit/spending limit (daily, weekly, or monthly cap)
data: player_id, limit_type, limit_period, limit_amount, currency, resets_at
player.account_blockedPlatform or operator blocked player account due to suspicious activity or compliance
data: player_id, blocked_by, reason, block_type (temporary|permanent), unblock_at
system.psp_degradedPlatform detected elevated failure rate on a PSP -- Cascade Manager now routing around it
data: psp_id, failure_rate, threshold, detected_at, expected_recovery_at
system.psp_recoveredPSP failure rate returned to normal -- routing restored to include it
data: psp_id, recovered_at, failure_rate_now
Payload Structure
data field contains the event-specific payload. This structure lets operators parse the envelope once and dispatch on type.| Field | Type | Description |
|---|---|---|
id | string (uuid) | Unique event ID -- operator must use this as idempotency key |
type | string | Event type (e.g. "deposit.completed") |
operator_id | string (uuid) | Operator this event belongs to |
brand_id | string (uuid) | Brand (casino site) within the operator |
created_at | string (ISO8601) | UTC timestamp when the event was created on the platform |
api_version | string | Webhook API version -- allows operators to handle schema changes gracefully |
data | object | Event-specific payload -- schema varies by event type (see catalog) |
Example -- deposit.completed
{
"id": "evt_01HZ9K3XMVQ7J8N4P2R6T5W0YB",
"type": "deposit.completed",
"operator_id": "op_7f3a2c1b-...",
"brand_id": "brand_a9e4f2d1-...",
"created_at": "2025-06-05T14:32:17.841Z",
"api_version": "2025-06-01",
"data": {
"transaction_id": "txn_4b8e1f92-...",
"player_id": "player_c3d7a2e0-...",
"settlement_amount": 99.60,
"settlement_currency": "USD",
"display_amount": 500.00,
"display_currency": "BRL",
"fx_rate": 5.020080,
"method": "card",
"psp_id": "psp_passimpay",
"psp_reference": "pp_TXN_8827461",
"wallet_balance_after": 199.60,
"completed_at": "2025-06-05T14:32:15.002Z"
}
}Signing & Security
Signing algorithm
HMAC-SHA256. The platform computes: HMAC-SHA256(signing_secret, timestamp + "." + raw_body) and sends the result in the X-Signature header. The operator must read raw_body (unparsed bytes) before any JSON parsing.
Timestamp header
Every request includes X-Timestamp (Unix seconds). Operator should reject events where abs(now - timestamp) > 300s (5 minutes). This prevents replay attacks -- an attacker replaying a valid captured webhook after 5 minutes will have the request rejected.
Secret rotation
Operators can rotate the signing secret from Admin Panel. During rotation, the platform accepts both old and new secrets for a 10-minute overlap window to allow zero-downtime rotation. After 10 minutes, only the new secret is valid.
HTTPS requirement
Operator endpoints must be HTTPS. The platform validates the SSL certificate and rejects self-signed certificates. HTTP endpoints are rejected at registration time -- they cannot be saved in webhook_endpoints.
Retry Logic & Delivery Guarantees
2xx response reached the platform after a timeout). Idempotent processing on event_id is mandatory on the operator side.| Attempt | Delay | Elapsed | If failed |
|---|---|---|---|
| 1 | Immediate | 0s | Schedule attempt 2 |
| 2 | 30 seconds | 30s | Schedule attempt 3 |
| 3 | 5 minutes | 5m 30s | Schedule attempt 4 |
| 4 | 30 minutes | 35m | Schedule attempt 5 |
| 5 | 2 hours | 2h 35m | Schedule attempt 6 |
| 6 | 6 hours | 8h 35m | Move to dead letter queue |
Success condition
Any 2xx response code from the operator endpoint within timeout_ms is counted as successful delivery. The response body is ignored -- only the status code matters. If the operator returns 200 but logs an internal error, the platform considers it delivered.
Dead letter queue
After 6 failed attempts (~8.5 hours), the delivery is moved to dead letter queue. Ops can inspect failed deliveries in Admin Panel, manually re-trigger them after the operator fixes their endpoint, or mark them as permanently abandoned.
Operator alerts
If an endpoint fails 3 consecutive deliveries, the operator receives an email alert (to their registered admin email). If it fails all 6 attempts for any critical event, ops is also notified internally.
Data Model
Table webhook_endpoints -- operator endpoints
| Column | Type | Description |
|---|---|---|
id | uuid PK | Endpoint record ID |
operator_id | uuid FK | Operator this endpoint belongs to |
url | text | HTTPS URL to POST events to -- must be HTTPS, validated on save |
signing_secret | text | HMAC-SHA256 secret -- generated by platform, shown once to operator, stored hashed |
subscribed_events | text[] | Array of event types this endpoint receives -- empty array means all events |
is_active | boolean | Inactive endpoints are skipped during delivery -- operator can disable without deleting |
timeout_ms | int | Max ms to wait for operator response -- default 5000. If exceeded, counted as failed attempt. |
created_at | timestamptz | When the endpoint was registered |
last_success_at | timestamptz | Timestamp of last successful delivery to this endpoint -- null if never succeeded |
Table webhook_deliveries -- delivery log
event_id and increasing attempt_number. The table is append-only -- rows are never updated after creation.| Column | Type | Description |
|---|---|---|
id | uuid PK | Delivery attempt record ID |
endpoint_id | uuid FK | Which endpoint this delivery was sent to |
event_id | uuid | Unique event ID -- same across all retry attempts for the same event (idempotency key) |
event_type | text | Event type string (e.g. deposit.completed) |
payload | jsonb | Full event payload as sent -- snapshot, never mutated after creation |
attempt_number | int | Which retry attempt this row represents (1 = first attempt) |
http_status | int | HTTP status code returned by operator -- null if request timed out or connection failed |
response_body | text | First 1000 chars of operator response body -- for debugging failed deliveries |
duration_ms | int | Time from request sent to response received (or timeout) |
status | text | PENDING | DELIVERED | FAILED | DEAD_LETTER |
next_attempt_at | timestamptz | Scheduled time for next retry -- null if DELIVERED or DEAD_LETTER |
created_at | timestamptz | When this delivery attempt was created |
Admin Panel Configuration
Register endpoint
Operator enters their HTTPS URL in Admin Panel. Platform validates URL format and performs a test ping (POST with test event type "webhook.test"). If the endpoint returns 2xx within 5s, registration succeeds. Signing secret is generated and shown once.
Event subscription
Operator selects which event types to receive via checkbox list in Admin Panel -- or selects "All events". Subscription can be changed at any time without re-registering the endpoint. Changes take effect for events generated after the save.
Multiple endpoints
One operator can register multiple endpoints -- e.g. one for payment events (high-priority, own server) and one for analytics events (lower priority, data warehouse). Each endpoint has its own subscription list, secret, and delivery log.
Delivery log in Admin Panel
Operator can see last 30 days of delivery history per endpoint: event type, attempt number, HTTP status, response time, status (DELIVERED / FAILED / DEAD_LETTER). Failed deliveries can be manually re-triggered from the UI after fixing the endpoint.
Secret rotation
Operator clicks "Rotate secret" in Admin Panel. New secret is shown once. Old secret remains valid for 10 minutes to allow zero-downtime rotation. A rotation event is logged in the audit trail.
Test event delivery
Operator can trigger a synthetic "webhook.test" event from Admin Panel at any time to verify the endpoint is responding correctly. Test events are clearly marked in delivery logs and are never confused with real payment events.