Webhook Integration
Complete guide for integrating PlaySuper webhooks to receive real-time transaction notifications
Overview
PlaySuper webhooks allow your server to receive real-time notifications when coin transactions occur in your games. Instead of polling our API, you'll receive HTTP POST requests to your configured endpoint whenever coins are credited, debited, or refunded.
Quick Start
1. Configure Your Webhook Endpoint
Authenticate with your Game API key in x-api-key, the same key every other PlaySuper endpoint uses. Webhooks are configured per studio, and the studio is inferred from the key, so configure needs no studioId. The response includes your studioId; keep it, the management endpoints further down take it as a query parameter. See API keys.
curl -X POST 'https://api.playsuper.club/studio-webhooks/configure' \
-H 'Content-Type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-d '{
"webhookUrl": "https://your-server.com/webhooks/playsuper",
"subscribedEvents": ["COINS_CREDITED", "COINS_DEBITED", "COINS_REFUNDED"],
"environment": "PRODUCTION"
}'Testing against the sandbox? Call https://dev.playsuper.club/studio-webhooks/configure with your test key and keep "environment": "PRODUCTION". The host you call decides which environment delivers events, not this field. See Environment Configuration.
2. Receive Webhook Events
Your endpoint will receive POST requests like:
{
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"event_type": "COINS_CREDITED",
"timestamp": "2025-01-15T10:30:00.000Z",
"app_id": "game_xyz",
"user_uuid": "player_12345",
"coin_id": "gold_coins",
"delta": 100,
"new_balance": 1500,
"reference_id": "txn_789",
"reason": "GAME_CREDIT"
}3. Respond with 2xx Status
Return any 2xx status code to acknowledge receipt. We'll retry on failures.
Webhook Events
| Event Type | Description |
|---|---|
COINS_CREDITED | Coins were added to a player's balance |
COINS_DEBITED | Coins were deducted from a player's balance |
COINS_REFUNDED | Coins were refunded to a player's balance (e.g., failed purchase reversal) |
Webhook Payload Structure
interface WebhookPayload {
event_id: string; // Unique event identifier
event_type: string; // "COINS_CREDITED", "COINS_DEBITED", or "COINS_REFUNDED"
timestamp: string; // ISO 8601 timestamp
app_id: string; // Your game ID
user_uuid: string; // Player's identifier in your system
coin_id: string; // Coin type identifier
delta: number; // Amount changed (positive for credit, negative for debit)
new_balance: number; // Player's new coin balance
reference_id: string; // Transaction reference ID
reason: string; // Transaction source (see below)
}Transaction Sources (reason field)
The reason field in the webhook payload indicates what triggered the transaction. Use this to distinguish your own API calls from PlaySuper-originated events.
Common reason values:
GAME_CREDITGAME_DEBITPURCHASE_DEBITREFUND_CREDIT
Other transaction sources (e.g. VOUCHER_PURCHASE, BONUS) may also appear — handle unknown values gracefully instead of assuming the list above is exhaustive.
| Source | Event Type | Description |
|---|---|---|
PURCHASE_DEBIT | COINS_DEBITED | Player purchased a reward from the PlaySuper store |
REFUND_CREDIT | COINS_REFUNDED | Coins refunded due to failed/expired payment |
Avoiding Double-Processing:
If you call the /distribute and /deduct APIs to sync balances, filter out webhook events with GAME_CREDIT or GAME_DEBIT sources in your server code so you do not process your own calls twice:
app.post('/webhooks/playsuper', (req, res) => {
const { reason } = req.body;
// Skip events from your own sync calls
if (reason === 'GAME_CREDIT' || reason === 'GAME_DEBIT') {
return res.status(200).json({ received: true, skipped: true });
}
// Process PlaySuper-originated events (store purchases, refunds)
// ...
});Alternatively, pass excludedReasons when configuring the webhook (e.g. "excludedReasons": ["GAME_CREDIT", "GAME_DEBIT"]) and PlaySuper will not send those events at all — no client-side filtering needed.
HTTP Headers
Every webhook request includes these headers:
| Header | Description | Example |
|---|---|---|
Content-Type | Always JSON | application/json |
User-Agent | Identifies PlaySuper | PlaySuper-Webhook/1.0 |
X-PlaySuper-Event-ID | Unique event ID | 550e8400-e29b-41d4-a716-446655440000 |
X-PlaySuper-Event-Type | Event type | COINS_CREDITED |
X-PlaySuper-Timestamp | Unix timestamp | 1705312200 |
X-PlaySuper-Attempt | Delivery attempt number | 1 |
X-PlaySuper-Signature | HMAC-SHA256 signature | t=1705312200,v1=5257a... |
Webhook Security (Signature Verification)
Every webhook includes an HMAC-SHA256 signature that you should verify to ensure the request is genuinely from PlaySuper.
Your Signing Secret
When you configure a webhook, the response includes a signing secret (for example whsec_abc123...). It is shown once. The list and get endpoints do not return it again, and there is no rotate call. Store it securely when you receive it. If you lose it, delete the config and configure the webhook again to receive a new secret.
Response from configure endpoint:
{
"id": "3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c",
"signingSecret": "whsec_7f8a9b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a",
"webhookUrl": "https://your-server.com/webhooks/playsuper",
...
}Signature Format
The X-PlaySuper-Signature header contains:
t=1705312200,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdt= Unix timestamp when the signature was generatedv1= HMAC-SHA256 signature
How to Verify
- Extract the timestamp and signature from the header
- Compute the expected signature:
HMAC-SHA256(timestamp + "." + raw_body, signing_secret) - Compare your computed signature with the one in the header
- Optionally, check that the timestamp is recent (within 5 minutes) to prevent replay attacks
Verification Examples
Node.js
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
const [tPart, v1Part] = signature.split(',');
const timestamp = tPart.split('=')[1];
const receivedSig = v1Part.split('=')[1];
// Check timestamp is within 5 minutes
const currentTime = Math.floor(Date.now() / 1000);
if (Math.abs(currentTime - parseInt(timestamp)) > 300) {
return false; // Timestamp too old
}
// Compute expected signature
const signedPayload = `${timestamp}.${payload}`;
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
// Constant-time comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(receivedSig),
Buffer.from(expectedSig)
);
}
// Express middleware
app.post('/webhooks/playsuper', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-playsuper-signature'];
const payload = req.body.toString();
if (!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(payload);
// Process verified webhook...
res.status(200).json({ received: true });
});Python
import hmac
import hashlib
import time
def verify_webhook_signature(payload: str, signature: str, secret: str) -> bool:
parts = dict(p.split('=') for p in signature.split(','))
timestamp = parts.get('t')
received_sig = parts.get('v1')
if not timestamp or not received_sig:
return False
# Check timestamp is within 5 minutes
current_time = int(time.time())
if abs(current_time - int(timestamp)) > 300:
return False
# Compute expected signature
signed_payload = f"{timestamp}.{payload}"
expected_sig = hmac.new(
secret.encode('utf-8'),
signed_payload.encode('utf-8'),
hashlib.sha256
).hexdigest()
# Constant-time comparison
return hmac.compare_digest(received_sig, expected_sig)
# Flask example
@app.route('/webhooks/playsuper', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-PlaySuper-Signature')
payload = request.get_data(as_text=True)
if not verify_webhook_signature(payload, signature, WEBHOOK_SECRET):
return jsonify({'error': 'Invalid signature'}), 401
event = request.json
# Process verified webhook...
return jsonify({'received': True}), 200Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"strconv"
"strings"
"time"
)
func verifyWebhookSignature(payload, signature, secret string) bool {
parts := make(map[string]string)
for _, p := range strings.Split(signature, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
timestamp := parts["t"]
receivedSig := parts["v1"]
// Check timestamp is within 5 minutes
ts, _ := strconv.ParseInt(timestamp, 10, 64)
if math.Abs(float64(time.Now().Unix()-ts)) > 300 {
return false
}
// Compute expected signature
signedPayload := timestamp + "." + payload
h := hmac.New(sha256.New, []byte(secret))
h.Write([]byte(signedPayload))
expectedSig := hex.EncodeToString(h.Sum(nil))
return hmac.Equal([]byte(receivedSig), []byte(expectedSig))
}Retry Policy
If your endpoint fails to respond with a 2xx status, we'll retry with exponential backoff:
| Attempt | Delay After Failure |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 15 minutes |
| 4 | 1 hour |
| 5 | 4 hours |
After 5 failed attempts, the webhook is marked as failed and moved to dead-letter queue.
The delays above are the intended schedule; actual redelivery timing is governed by the delivery queue's retry policy, so treat them as approximate.
Timeout: Your endpoint must respond within 30 seconds.
API Reference
Response examples below show the data payload of the standard response envelope.
Configure Webhook
Create or update webhook configuration for your studio.
curl -X POST 'https://api.playsuper.club/studio-webhooks/configure' \
-H 'Content-Type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-d '{
"webhookUrl": "https://your-server.com/webhooks/playsuper",
"subscribedEvents": ["COINS_CREDITED", "COINS_DEBITED", "COINS_REFUNDED"],
"environment": "PRODUCTION"
}'| Body field | Type | Required | Description |
|---|---|---|---|
webhookUrl | string | Yes | HTTPS endpoint that will receive the events |
subscribedEvents | array | Yes | Any of COINS_CREDITED, COINS_DEBITED, COINS_REFUNDED |
environment | string | Yes | Always PRODUCTION. DEVELOPMENT is accepted by validation but never receives events (see Environment Configuration) |
excludedReasons | array | No | Transaction reason values to suppress server-side (e.g. ["GAME_CREDIT", "GAME_DEBIT"]) |
isActive | boolean | No | Set false to disable the webhook without deleting it |
Response:
{
"id": "3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c",
"studioId": "studio_123",
"webhookUrl": "https://your-server.com/webhooks/playsuper",
"subscribedEvents": ["COINS_CREDITED", "COINS_DEBITED"],
"excludedReasons": [],
"environment": "PRODUCTION",
"isActive": true,
"signingSecret": "whsec_7f8a9b2c3d4e5f6a7b8c9d0e1f2a3b4c...",
"createdAt": "2025-01-15T10:00:00.000Z"
}List Webhook Configs
studioId is required on this and every endpoint below. It is the studioId field in the configure response.
curl -X GET 'https://api.playsuper.club/studio-webhooks/config?studioId=studio_123' \
-H 'x-api-key: YOUR_API_KEY'Returns each config without its signingSecret.
Get Specific Config
curl -X GET 'https://api.playsuper.club/studio-webhooks/config/3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c?studioId=studio_123' \
-H 'x-api-key: YOUR_API_KEY'Update Webhook Config
curl -X PUT 'https://api.playsuper.club/studio-webhooks/config/3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c?studioId=studio_123' \
-H 'Content-Type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-d '{
"webhookUrl": "https://new-server.com/webhooks/playsuper",
"isActive": true
}'Delete Webhook Config
curl -X DELETE 'https://api.playsuper.club/studio-webhooks/config/3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c?studioId=studio_123' \
-H 'x-api-key: YOUR_API_KEY'Send Test Event
Test your webhook integration without affecting real data.
curl -X POST 'https://api.playsuper.club/studio-webhooks/test?studioId=studio_123&configId=3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c' \
-H 'Content-Type: application/json' \
-H 'x-api-key: YOUR_API_KEY' \
-d '{
"eventType": "COINS_CREDITED"
}'Get Delivery History
View webhook delivery attempts and their status.
curl -X GET 'https://api.playsuper.club/studio-webhooks/deliveries?studioId=studio_123&status=FAILED&limit=10' \
-H 'x-api-key: YOUR_API_KEY'Query Parameters:
status: Filter by status (SUCCESS,FAILED,PENDING,RETRYING)eventType: Filter by event type (COINS_CREDITED,COINS_DEBITED,COINS_REFUNDED)limit: Number of results (default: 20, max: 100)skip: Pagination offset
Response:
{
"data": [
{
"id": "8a7b6c5d-4e3f-2a1b-0c9d-8e7f6a5b4c3d",
"eventId": "550e8400-e29b-41d4-a716-446655440000",
"configId": "3f2a1b4c-5d6e-7f8a-9b0c-1d2e3f4a5b6c",
"attemptNumber": 3,
"status": "FAILED",
"httpStatusCode": 500,
"errorMessage": "Request failed with status code 500",
"attemptedAt": "2025-01-15T10:31:00.000Z",
"nextRetryAt": "2025-01-15T10:46:00.000Z",
"createdAt": "2025-01-15T10:30:00.000Z",
"event": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"eventType": "COINS_DEBITED",
"transactionId": "txn-789",
"createdAt": "2025-01-15T10:30:00.000Z"
}
}
],
"pagination": {
"total": 30,
"limit": 10,
"skip": 0,
"hasMore": true
}
}Manual retry of a failed delivery is not supported — failed deliveries are retried automatically per the retry policy.
Get Webhook Stats
Get delivery statistics for your webhooks.
curl -X GET 'https://api.playsuper.club/studio-webhooks/stats?studioId=studio_123' \
-H 'x-api-key: YOUR_API_KEY'Response:
{
"totalEvents": 1250,
"deliveredEvents": 1200,
"failedEvents": 30,
"successRate": "96.00%"
}Implementation Examples
Node.js (Express)
const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhooks/playsuper', (req, res) => {
const eventId = req.headers['x-playsuper-event-id'];
const eventType = req.headers['x-playsuper-event-type'];
console.log(`Received${eventType} event:${eventId}`);
const { user_uuid, coin_id, delta, new_balance, reference_id, reason } = req.body;
// Skip events from your own sync calls to avoid double-processing
if (reason === 'GAME_CREDIT' || reason === 'GAME_DEBIT') {
console.log(`Skipping self-originated event:${reason}`);
return res.status(200).json({ received: true, skipped: true });
}
// Process PlaySuper-originated events
switch (req.body.event_type) {
case 'COINS_CREDITED':
// Update your database, notify user, etc.
console.log(`User${user_uuid} received${delta}${coin_id}`);
break;
case 'COINS_DEBITED':
// Handle debit event (e.g., PURCHASE_DEBIT from PlaySuper store)
console.log(`User${user_uuid} spent${Math.abs(delta)}${coin_id}`);
break;
case 'COINS_REFUNDED':
// Handle refund event (REFUND_CREDIT - coins returned due to failed purchase)
console.log(`User${user_uuid} was refunded${delta}${coin_id}`);
break;
}
// Always respond quickly with 200
res.status(200).json({ received: true });
});
app.listen(3000);Python (Flask)
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/webhooks/playsuper', methods=['POST'])
def handle_webhook():
event_id = request.headers.get('X-PlaySuper-Event-ID')
event_type = request.headers.get('X-PlaySuper-Event-Type')
print(f"Received{event_type} event:{event_id}")
payload = request.json
user_uuid = payload['user_uuid']
delta = payload['delta']
new_balance = payload['new_balance']
reason = payload.get('reason')
# Skip events from your own sync calls to avoid double-processing
if reason in ('GAME_CREDIT', 'GAME_DEBIT'):
print(f"Skipping self-originated event:{reason}")
return jsonify({'received': True, 'skipped': True}), 200
# Process PlaySuper-originated events
if payload['event_type'] == 'COINS_CREDITED':
print(f"User{user_uuid} received{delta} coins")
elif payload['event_type'] == 'COINS_DEBITED':
# PURCHASE_DEBIT - player bought from PlaySuper store
print(f"User{user_uuid} spent{abs(delta)} coins")
elif payload['event_type'] == 'COINS_REFUNDED':
# REFUND_CREDIT - coins returned due to failed purchase
print(f"User{user_uuid} was refunded{delta} coins")
return jsonify({'received': True}), 200
if __name__ == '__main__':
app.run(port=3000)Go
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
)
type WebhookPayload struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
Timestamp string `json:"timestamp"`
AppID string `json:"app_id"`
UserUUID string `json:"user_uuid"`
CoinID string `json:"coin_id"`
Delta int `json:"delta"`
NewBalance int `json:"new_balance"`
ReferenceID string `json:"reference_id"`
Reason string `json:"reason"`
}
func webhookHandler(w http.ResponseWriter, r *http.Request) {
eventID := r.Header.Get("X-PlaySuper-Event-ID")
eventType := r.Header.Get("X-PlaySuper-Event-Type")
log.Printf("Received %s event: %s", eventType, eventID)
var payload WebhookPayload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
// Skip events from your own sync calls to avoid double-processing
if payload.Reason == "GAME_CREDIT" || payload.Reason == "GAME_DEBIT" {
log.Printf("Skipping self-originated event: %s", payload.Reason)
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]interface{}{"received": true, "skipped": true})
return
}
// Process PlaySuper-originated events
switch payload.EventType {
case "COINS_CREDITED":
log.Printf("User %s received %d coins", payload.UserUUID, payload.Delta)
case "COINS_DEBITED":
// PURCHASE_DEBIT - player bought from PlaySuper store
log.Printf("User %s spent %d coins", payload.UserUUID, -payload.Delta)
case "COINS_REFUNDED":
// REFUND_CREDIT - coins returned due to failed purchase
log.Printf("User %s was refunded %d coins", payload.UserUUID, payload.Delta)
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}
func main() {
http.HandleFunc("/webhooks/playsuper", webhookHandler)
log.Fatal(http.ListenAndServe(":3000", nil))
}Best Practices
1. Respond Quickly
Process webhooks asynchronously. Return 200 immediately and handle the event in a background job.
app.post('/webhooks/playsuper', async (req, res) => {
// Acknowledge immediately
res.status(200).json({ received: true });
// Process asynchronously
processWebhookAsync(req.body).catch(console.error);
});2. Handle Duplicates (Idempotency)
Webhooks may be delivered more than once. Use event_id to detect duplicates.
app.post('/webhooks/playsuper', async (req, res) => {
const eventId = req.body.event_id;
// Check if already processed
if (await isEventProcessed(eventId)) {
return res.status(200).json({ received: true, duplicate: true });
}
// Mark as processing
await markEventProcessing(eventId);
// Process event...
res.status(200).json({ received: true });
});3. Verify Signatures
Always verify the X-PlaySuper-Signature header to ensure requests are genuinely from PlaySuper. See the Webhook Security section for implementation examples.
The signature covers the raw request body. Read it with express.raw() (not express.json()) on this route, and verify the string before parsing it:
app.post('/webhooks/playsuper', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-playsuper-signature'];
const rawBody = req.body.toString();
if (!verifyWebhookSignature(rawBody, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(rawBody);
// Process verified webhook...
});Passing a parsed req.body object stringifies to [object Object] and every check fails.
4. Use HTTPS
Always use HTTPS endpoints in production to ensure data security.
Troubleshooting
Webhook Not Received
-
Check your endpoint is publicly accessible
-
Verify firewall allows incoming requests
-
Check delivery history for errors:
curl -X GET 'https://api.playsuper.club/studio-webhooks/deliveries?studioId=studio_123&status=FAILED' \ -H 'x-api-key: YOUR_API_KEY'
Timeout Errors
- Ensure your endpoint responds within 30 seconds
- Process webhooks asynchronously
- Return 200 immediately, handle logic in background
Duplicate Events
- Implement idempotency using
event_id - Store processed event IDs in your database
- Check before processing
Environment Configuration
| Endpoint | Key | Use Case | Environment Value |
|---|---|---|---|
https://dev.playsuper.club/studio-webhooks/... | Test key (ps_test_) | Sandbox events from your test builds | PRODUCTION |
https://api.playsuper.club/studio-webhooks/... | Live key (ps_live_) | Live production traffic | PRODUCTION |
Always send "environment": "PRODUCTION". Each host (dev and api) delivers only to configs marked PRODUCTION in its own database, so the host you call is what separates sandbox from live. DEVELOPMENT passes validation but no events are ever routed to it; a config created with it silently receives nothing.
Configure a separate webhook URL on each host:
# Development
curl -X POST 'https://dev.playsuper.club/studio-webhooks/configure' \
-H 'x-api-key: YOUR_TEST_API_KEY' \
-d '{
"webhookUrl": "https://dev.your-server.com/webhooks/playsuper",
"environment": "PRODUCTION"
}'
# Production
curl -X POST 'https://api.playsuper.club/studio-webhooks/configure' \
-H 'x-api-key: YOUR_LIVE_API_KEY' \
-d '{
"webhookUrl": "https://your-server.com/webhooks/playsuper",
"environment": "PRODUCTION"
}'Related Guides
- API Reference - REST API for player management, coins, and rewards
- Touchpoints API - Configure visual reward widgets
- Gift Card API - Purchase and distribute gift card vouchers
- Console Setup - Set up your account and generate API keys