Bonus flow
Bidirectional contract for issuing + tracking bonuses on the player's wallet.
Lifecycle
[bonus.credited]
CRM grant → adapter.grantBonus → ───────────────────→ active
│
[bonus.wagered] (N×)
←────────────────────────┤
│
[bonus.completed] │
←────────────────────────┴──→ completed (TERMINAL)
alternative terminals:
[bonus.expired] → expired
[bonus.cancelled] → cancelled (admin / fraud)
[bonus.forfeited] → forfeited (max bet violation, etc.)
CRM → Adapter (outbound)
When a CRM journey, campaign, or admin issues a bonus, UCRM calls your adapter:
POST <your-base>/v1/bonuses/grant
Headers:
Authorization: Bearer <CRM-API-key-or-platform-token>(your auth scheme)X-UCRM-Signature: <hex-hmac>(UCRM signs outbound calls)Content-Type: application/json
Body:
{
"grant_id": "ucrm-uuid-correlation-key", // store + echo on every webhook
"tenant_id": "ucrm-tenant-uuid",
"player_external_id": "casino_player_001",
"template": {
"type": "deposit_match",
"amount_subunit": 5000, // €50 in cents (issue value)
"currency": "EUR",
"wagering": {
"multiplier": 30,
"basis": "bonus_plus_deposit",
"max_bet_subunit": 500 // €5 in cents
},
"expires_at": "2026-06-08T...",
"config": { // type-specific config blob
"match_percent": 100,
"cap_amount": 5000,
"deposit_amount": 5000 // for deposit_match — the trigger deposit
}
},
"granted_by": {
"kind": "journey", // manual | api | journey | campaign | auto
"ref": "journey_run_uuid"
}
}
Success response (201):
{
"platform_bonus_id": "platform-side-bonus-id",
"status": "credited" // "credited" if synchronous, "pending" if async
}
Error responses:
// 4xx — non-retriable
{ "code": "PLAYER_BLOCKED", "message": "Player is self-excluded" }
{ "code": "DUPLICATE_GRANT", "message": "Already issued for grant_id X" }
{ "code": "TEMPLATE_NOT_SUPPORTED", "message": "..." }
// 5xx — retriable
{ "code": "INTERNAL_ERROR", "message": "..." }
POST <your-base>/v1/bonuses/cancel
{
"grant_id": "ucrm-uuid",
"tenant_id": "...",
"platform_bonus_id": "platform-side-id",
"reason": "fraud_review"
}
Returns 200 { ok: true } then fires bonus.cancelled webhook (asynchronously) when the cancel actually completes on platform.
GET <your-base>/v1/bonuses/:platform_bonus_id
Used by daily reconciliation to compare CRM state vs platform state.
// 200 response
{
"status": "active", // active | completed | forfeited | expired | cancelled
"wagering_progress_subunit": 60000, // weighted progress in subunit
"total_wagered_subunit": 60000,
"total_won_from_bonus_subunit": 0
}
404 if platform doesn't recognise the id (means CRM's view is wrong — log + audit).
Adapter → CRM (webhooks)
Fire these as the bonus moves through its lifecycle. Always include grant_id so CRM can correlate.
POST https://api.casinocrm.io/v1/adapters/<your-slug>/webhooks/bonus-events
Headers:
X-UCRM-Signature: <hex-hmac-sha256-over-timestamp.body>X-UCRM-Timestamp: <unix-seconds>
bonus.credited:
{
"event": "bonus.credited",
"event_id": "evt-uuid-unique",
"ts": "2026-05-08T12:00:00Z",
"grant_id": "ucrm-uuid",
"platform_bonus_id": "platform-id",
"data": {
"credited_amount_subunit": 5000,
"currency": "EUR"
}
}
bonus.wagered (fire after each settled bet that contributes to wagering):
{
"event": "bonus.wagered",
"event_id": "evt-uuid",
"ts": "...",
"grant_id": "ucrm-uuid",
"platform_bonus_id": "platform-id",
"data": {
"wagered_amount_subunit": 1000,
"weighted_progress_subunit": 1000, // amount × game_contribution_pct / 100
"bet_id": "bet-uuid"
}
}
bonus.completed:
{
"event": "bonus.completed",
"event_id": "evt-uuid",
"ts": "...",
"grant_id": "ucrm-uuid",
"platform_bonus_id": "platform-id",
"data": {
"completed_at": "...",
"total_wagered_subunit": 150000,
"total_won_from_bonus_subunit": 12000
}
}
bonus.expired:
{
"event": "bonus.expired",
"event_id": "evt-uuid",
"ts": "...",
"grant_id": "ucrm-uuid",
"platform_bonus_id": "platform-id",
"data": {}
}
bonus.cancelled:
{
"event": "bonus.cancelled",
"event_id": "evt-uuid",
"ts": "...",
"grant_id": "ucrm-uuid",
"platform_bonus_id": "platform-id",
"data": { "reason": "fraud_review" }
}
bonus.forfeited:
{
"event": "bonus.forfeited",
"event_id": "evt-uuid",
"ts": "...",
"grant_id": "ucrm-uuid",
"platform_bonus_id": "platform-id",
"data": { "reason": "max_bet_violation:8500_max_500" }
}
Reconciliation
UCRM runs pnpm reconcile:bonuses daily. For each active grant in your adapter's tenants:
- Calls
GET <your-base>/v1/bonuses/:platform_bonus_id - If platform's status is a forward transition from CRM's status, replays the missing webhook locally (synthetic event through the same state-sync pipeline).
- If status is incompatible (CRM
activebut platformcompleted, missed webhook), forces transition + logs structured drift entry for ops.
This handles webhook drops + partial outages gracefully — you don't need to implement perfect at-most-once delivery; UCRM closes the gap nightly.