PlaySuper LogoPlaySuper
API Reference

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 TypeDescription
COINS_CREDITEDCoins were added to a player's balance
COINS_DEBITEDCoins were deducted from a player's balance
COINS_REFUNDEDCoins 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_CREDIT
  • GAME_DEBIT
  • PURCHASE_DEBIT
  • REFUND_CREDIT

Other transaction sources (e.g. VOUCHER_PURCHASE, BONUS) may also appear — handle unknown values gracefully instead of assuming the list above is exhaustive.

SourceEvent TypeDescription
PURCHASE_DEBITCOINS_DEBITEDPlayer purchased a reward from the PlaySuper store
REFUND_CREDITCOINS_REFUNDEDCoins 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:

HeaderDescriptionExample
Content-TypeAlways JSONapplication/json
User-AgentIdentifies PlaySuperPlaySuper-Webhook/1.0
X-PlaySuper-Event-IDUnique event ID550e8400-e29b-41d4-a716-446655440000
X-PlaySuper-Event-TypeEvent typeCOINS_CREDITED
X-PlaySuper-TimestampUnix timestamp1705312200
X-PlaySuper-AttemptDelivery attempt number1
X-PlaySuper-SignatureHMAC-SHA256 signaturet=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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t = Unix timestamp when the signature was generated
  • v1 = HMAC-SHA256 signature

How to Verify

  1. Extract the timestamp and signature from the header
  2. Compute the expected signature: HMAC-SHA256(timestamp + "." + raw_body, signing_secret)
  3. Compare your computed signature with the one in the header
  4. 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}), 200

Go

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:

AttemptDelay After Failure
11 minute
25 minutes
315 minutes
41 hour
54 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 fieldTypeRequiredDescription
webhookUrlstringYesHTTPS endpoint that will receive the events
subscribedEventsarrayYesAny of COINS_CREDITED, COINS_DEBITED, COINS_REFUNDED
environmentstringYesAlways PRODUCTION. DEVELOPMENT is accepted by validation but never receives events (see Environment Configuration)
excludedReasonsarrayNoTransaction reason values to suppress server-side (e.g. ["GAME_CREDIT", "GAME_DEBIT"])
isActivebooleanNoSet 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

  1. Check your endpoint is publicly accessible

  2. Verify firewall allows incoming requests

  3. 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

EndpointKeyUse CaseEnvironment Value
https://dev.playsuper.club/studio-webhooks/...Test key (ps_test_)Sandbox events from your test buildsPRODUCTION
https://api.playsuper.club/studio-webhooks/...Live key (ps_live_)Live production trafficPRODUCTION

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"
  }'