Outbound Webhooks

Platform-to-operator push notifications -- payment events, retry logic, signing, and delivery guarantees

Next
Spec

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.

1.

Why It Is Needed

Outbound Webhooks are the platform's outward-facing event bus. Each operator registers one or more HTTPS endpoints and subscribes to the event types they need. The platform push-delivers signed JSON payloads on every significant payment event. The operator responds with 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.

2.

Event Catalog

Every event carries a unique event_id (UUID). Operators must store processed event_ids and return 2xx on re-delivery of the same event without reprocessing. Events marked
Critical
require reliable idempotent handling on the operator side.
deposit.initiated
Deposit

Player submits deposit form -- PSP call not yet made

data: transaction_id, player_id, amount, currency, method, psp_id

deposit.processing
Deposit

PSP accepted the request -- awaiting confirmation (e.g. crypto awaiting blockchain)

data: transaction_id, psp_reference, estimated_confirmation_time

deposit.completed
Deposit
Critical

PSP 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.failed
Deposit
Critical

PSP declined or transaction expired -- no balance change

data: transaction_id, player_id, failure_reason, failure_code, psp_id

deposit.cascade_fallback
Deposit

First PSP failed -- Cascade Manager is retrying with next PSP

data: transaction_id, failed_psp_id, next_psp_id, attempt_number

withdrawal.requested
Withdrawal
Critical

Player submitted withdrawal request -- pending approval or auto-processing

data: transaction_id, player_id, amount, currency, method, kyc_level

withdrawal.approved
Withdrawal
Critical

Ops or auto-approval rule approved the withdrawal -- PSP call imminent

data: transaction_id, player_id, approved_by (user_id or "auto"), approved_at

withdrawal.rejected
Withdrawal
Critical

Ops rejected the withdrawal manually -- balance hold released back to player

data: transaction_id, player_id, rejection_reason, rejected_by, wallet_balance_after

withdrawal.completed
Withdrawal
Critical

PSP confirmed funds sent to player -- balance deducted from Wallet

data: transaction_id, player_id, settlement_amount, psp_reference, wallet_balance_after

withdrawal.failed
Withdrawal
Critical

PSP failed to send funds -- balance hold released back to player

data: transaction_id, player_id, failure_reason, failure_code, wallet_balance_after

chargeback.opened
Chargeback
Critical

PSP notified platform of a new dispute on a transaction

data: chargeback_id, transaction_id, player_id, amount, reason_code, evidence_deadline

chargeback.won
Chargeback
Critical

Card network ruled in platform's favor -- disputed funds returned

data: chargeback_id, transaction_id, player_id, amount, resolved_at

chargeback.lost
Chargeback
Critical

Card network ruled in cardholder's favor -- funds permanently reversed

data: chargeback_id, transaction_id, player_id, amount, resolved_at

kyc.level_upgraded
KYC

Player completed a verification step -- KYC level increased

data: player_id, previous_level, new_level, verified_at, documents_submitted

kyc.rejected
KYC

KYC provider rejected submitted documents

data: player_id, rejection_reason, rejected_documents, can_resubmit

kyc.documents_expired
KYC

Player'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_excluded
Player
Critical

Player activated self-exclusion -- all payment processing blocked

data: player_id, exclusion_type (temporary|permanent), exclusion_ends_at (null if permanent)

player.limit_reached
Player

Player 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_blocked
Player
Critical

Platform 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_degraded
System

Platform 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_recovered
System

PSP failure rate returned to normal -- routing restored to include it

data: psp_id, recovered_at, failure_rate_now

3.

Payload Structure

All events share the same top-level envelope. The data field contains the event-specific payload. This structure lets operators parse the envelope once and dispatch on type.
FieldTypeDescription
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"
  }
}
4.

Signing & Security

The operator must verify the signature on every incoming webhook before processing the payload. Skipping signature verification allows anyone to send fake events to the operator's endpoint.

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.

5.

Retry Logic & Delivery Guarantees

Delivery guarantee is at-least-once. The operator may receive the same event more than once (e.g. if the operator's 2xx response reached the platform after a timeout). Idempotent processing on event_id is mandatory on the operator side.
AttemptDelayElapsedIf failed
1Immediate0sSchedule attempt 2
230 seconds30sSchedule attempt 3
35 minutes5m 30sSchedule attempt 4
430 minutes35mSchedule attempt 5
52 hours2h 35mSchedule attempt 6
66 hours8h 35mMove 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.

6.

Data Model

Table webhook_endpoints -- operator endpoints

ColumnTypeDescription
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

Each delivery attempt is a separate row. One event with 3 attempts produces 3 rows with the same event_id and increasing attempt_number. The table is append-only -- rows are never updated after creation.
ColumnTypeDescription
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
7.

Admin Panel Configuration

The signing secret is shown to the operator only once at endpoint registration. The platform stores only the hash. If the operator loses the secret -- they must rotate it via Admin Panel. Always warn the operator about this in the UI when displaying the secret.

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.