Free spins webhook
One of the platform → provider webhooks, like the session revoke webhook: this direction goes the other way, the platform calls your server, not the reverse. It fires when the free-spins promotion tool — driven by an admin or by the operator's own signed API — issues, checks, or cancels a batch of free spins for one of your games. Free-spin execution and math stay entirely on your side: the platform only tells you to grant/query/cancel a batch and keeps a local record for audit and idempotency, nothing more.
Implement all three routes below (that exact path, at the baseUrl your integration contact registered for you — the same one your session revoke webhook already lives at) and verify every call with @moosehq/provider-sdk's verifyPlatformSignature before trusting it, exactly like the session revoke webhook.
Signing
Signed the same way every other platform-to-provider call is: X-Tenant-ID, X-Timestamp, X-Nonce, X-Signature headers, HMAC-SHA256 over method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + body using your provider secret. X-Tenant-ID carries your own provider tenant ID.
Unlike your own outbound calls via ProviderClient, the platform does not retry a failed call to any of these three routes. If your endpoint successfully grants/cancels the spins but the response is lost in transit (a timeout, a dropped connection), the platform has no way to know that — it records the attempt as failed on its side and will not automatically try again. Key your handling off requestRef/externalRef (see below) so a follow-up call for the same batch is safe to process idempotently rather than assuming every call you receive is necessarily a first attempt.
POST /v1/game/free-spins/grant
Request:
type GrantFreeSpinsRequest = {
requestRef: string // the platform's own ID for this grant — see below
playerRef: string
gameId: string
spins: number
betAmountMinor: number // minor currency units; 0 means "use your default stake"
currency: string
}requestRef is the platform's local identifier for this grant batch — it does not by itself guarantee you'll only ever see it once (see the no-retry note above), so if you want server-side deduplication on your end, key it off requestRef rather than assuming one call per grant.
Response: 200 with the batch identifier you'll use for status/cancel calls on the same grant:
type GrantFreeSpinsResponse = {
externalRef: string
}Any other status is treated as a failure — the platform marks the grant failed on its side (with your response body included in its internal error log, so a descriptive error body helps whoever's debugging this later) and does not retry.
POST /v1/game/free-spins/status
Request:
type FreeSpinsStatusRequest = {
externalRef: string // from the grant call's response
playerRef: string
gameId: string
}Response:
type FreeSpinsStatusResponse = {
remainingSpins: number
completed: boolean
}This is a pull, not a push — nothing on the platform polls it automatically today. It's called on demand (e.g. an admin looking up a specific grant).
POST /v1/game/free-spins/cancel
Request:
type CancelFreeSpinsRequest = {
externalRef: string
playerRef: string
gameId: string
}Response: 200 on success (body ignored — an empty body is fine). Void the batch's remaining, unused spins on your side; a spin the player had already taken before the cancel arrived stands.
Any non-200 is treated as a failure, and — unlike the session revoke webhook, where the session is already gone regardless of whether the notification lands — the platform does not mark its local grant cancelled if this call fails. From the platform's point of view the spins may still be live on your side, so its own record deliberately keeps saying so until a cancel actually succeeds.
Example (Node/Express)
import { verifyPlatformSignature } from '@moosehq/provider-sdk'
function verifyOrReject(req: Request, res: Response): string | undefined {
const result = verifyPlatformSignature({
method: req.method,
path: req.path,
headers: req.headers,
body: req.body,
secret: process.env.PLATFORM_SECRET!,
now: new Date(),
expectedTenantId: 'acme-studio', // your own provider tenant ID
})
if (!result.ok) {
res.status(401).json({ error: result.reason })
return undefined
}
return req.body
}
// req.body must be the exact raw string the platform signed, so these
// routes need the raw body, not JSON-parsed middleware.
const rawBody = express.text({ type: '*/*' })
app.post('/v1/game/free-spins/grant', rawBody, async (req, res) => {
const raw = verifyOrReject(req, res)
if (!raw) return
const { requestRef, playerRef, gameId, spins, betAmountMinor, currency } = JSON.parse(raw)
const externalRef = await freeSpinsStore.grant({ requestRef, playerRef, gameId, spins, betAmountMinor, currency })
res.json({ externalRef })
})
app.post('/v1/game/free-spins/status', rawBody, async (req, res) => {
const raw = verifyOrReject(req, res)
if (!raw) return
const { externalRef } = JSON.parse(raw)
const batch = await freeSpinsStore.get(externalRef)
res.json({ remainingSpins: batch.remainingSpins, completed: batch.completed })
})
app.post('/v1/game/free-spins/cancel', rawBody, async (req, res) => {
const raw = verifyOrReject(req, res)
if (!raw) return
const { externalRef } = JSON.parse(raw)
await freeSpinsStore.cancelRemaining(externalRef)
res.sendStatus(200)
})Winnings from a free spin are not part of this contract — submit them through POST /v1/wallet/transaction as a normal WIN the same way you would for a real-money spin (see the Wallet API reference). This webhook only ever moves the "how many spins are left" bookkeeping, never money.
See verifyPlatformSignature in the session revoke webhook reference for its exact signature and error reasons — it's the same function, reused here.