Skip to main content

Webhooks

Webhooks allow your backend systems to receive real-time notifications about events occurring within PlaySafe, particularly when moderation actions are triggered by your Action Policies.

For example, when PlaySafe automatically applies a 10-day voice chat ban to a toxic user based on your policy, a webhook event can be sent to your server.

This enables you to implement custom logic in your own systems based on these events. PlaySafe sends detailed information about the event, allowing you to take appropriate actions within your game's ecosystem (e.g., updating a player's status in your database, logging the event in your own analytics, notifying community managers).

Webhooks are delivered as HTTP POST requests to an endpoint URL that you configure.

You can view and manage your webhook configurations from the PlaySafe dashboard.

How to Integrate Webhooks​

Prerequisites​

  • An Action Policy must be created and enabled in your PlaySafe dashboard.
  • The Action Policy must be configured to trigger actions (e.g., timeouts, mutes).
  • You need a server endpoint (URL) capable of receiving HTTP POST requests with a JSON payload. It must be an https:// address on a publicly resolvable host - private, loopback and link-local addresses are rejected.

Integration Steps​

  1. Configure Webhook URL in PlaySafe Dashboard:

    • Log in to your PlaySafe Dashboard.
    • Navigate to the Settings > Webhooks page (or similar section).
    • Click the "Add Webhook URL" (or equivalent) button.
    • Enter the public URL of your server endpoint that will listen for webhook events.
    • Save the configuration.
  2. Understand the Webhook Request Format: Webhook events will be sent as HTTP POST requests to your configured URL. The request body will contain a JSON payload with the following structure:

    interface WebhookPayload {
    /** ISO 8601 date string (YYYY-MM-DDTHH:mm:ss.sssZ) representing when the action ends */
    endDate: string;
    /** Name of the policy that triggered the action */
    trigger: string;
    /** URL to the audio file that triggered the action (if applicable, may be empty or null) */
    audioUrl: string | null;
    /** UUID of the product (your application) */
    productId: string;
    /** Transcribed text from the audio (if applicable, may be empty or null) */
    transcript: string | null;
    /** The specific violation detected - This corresponds to the 'Action Value' you configured in the PlaySafe policy */
    actionValue: string;
    /** Human-readable description of the action taken */
    description: string;
    /** ID of the player the action is taken against */
    playerUserId: string;
    /** Delay in seconds before the action takes effect (0 for immediate) */
    delayInSeconds: number;
    /** Duration of the action in minutes */
    durationInMinutes: number;
    /** Human-readable name of the action (e.g., 'Temporary Voice Ban') */
    actionFriendlyName: string;
    }
  3. Implement Your Endpoint Logic: Your server endpoint should be designed to:

    • Receive the POST request, keeping the raw request body available. You need the exact bytes to verify the signature - see Verifying Webhook Signatures.
    • Verify the X-PlaySafe-Signature header before trusting the payload.
    • Parse the JSON payload.
    • Process the event data according to your game's logic. Examples:
      • Update the player's status in your game database (e.g., set isVoiceMuted = true, store muteExpiryDate = endDate).
      • Log the event to your internal monitoring or analytics systems.
      • Notify administrators or community managers.
    • Respond with a 2xx status as soon as you have verified and queued the event. An empty body is fine; PlaySafe records whatever you return, up to 8 KB, on the delivery record.
    Deliveries are not retried

    PlaySafe sends each event once. A non-2xx response, a timeout, or an unparseable response body is recorded as a failed delivery on the Webhooks page in your dashboard, but it is not resent. Acknowledge quickly and queue slow work on your side rather than doing it before you respond. Requests time out after 10 seconds.

Example Webhook Payload​

{
"endDate": "2025-01-29T09:31:07.469Z",
"trigger": "Toxicity Policy - High Severity",
"audioUrl": "https://storage.example.com/audio-clips/clip-xyz.wav",
"productId": "your-product-uuid",
"transcript": "Go kill yourself you idiot!",
"actionValue": "Severe Harassment",
"description": "Player received a 1 hour voice ban for severe harassment.",
"playerUserId": "player-789",
"delayInSeconds": 0,
"durationInMinutes": 60,
"actionFriendlyName": "1 Hour Voice Ban"
}

Verifying Webhook Signatures​

Your webhook endpoint is a public URL, so anyone who learns it can POST to it. Every PlaySafe delivery is signed so your backend can confirm that a request genuinely came from PlaySafe and that nobody altered the payload in transit.

Verification is optional but strongly recommended. If you do not verify, treat your endpoint as accepting untrusted input.

The Signing Secret​

Each webhook URL has its own signing secret, so a compromise or rotation of one endpoint never affects the others.

To find it, open your PlaySafe Dashboard, go to Webhooks, and use the copy button on the webhook URL you are integrating. You do not need to reveal the secret to copy it. Secrets look like this:

psafe_whsec_4f8a1c93e7b2d05a6c1e9f3b8d24a70c5e6b19d3f2a84c07e1b5d9a3c6f28e04

Viewing and rotating signing secrets requires an organization admin role.

Store the secret the way you store any other credential - in an environment variable or a secrets manager, never in source control and never in client-side or game-client code.

The Signature Header​

Every request carries an X-PlaySafe-Signature header:

X-PlaySafe-Signature: t=1757372400,v1=5e1b6a...c04f
ElementMeaning
tUnix timestamp, in seconds, of when PlaySafe sent the request
v1Hex-encoded HMAC-SHA256 signature

To verify a request:

  1. Read the raw request body as a string, before any JSON parsing.
  2. Build the signed payload by joining the timestamp and the raw body with a period: `${t}.${rawBody}`.
  3. Compute an HMAC-SHA256 of that string using your signing secret as the key, hex-encoded.
  4. Compare your result against each v1 value using a constant-time comparison.
  5. Reject the request if t is more than five minutes from your own clock.
Use the raw body, not a re-serialized one

Sign the exact bytes you received. JSON.parse followed by JSON.stringify will reorder keys, change number formatting, and strip whitespace, producing a different string and a signature that never matches. This is by far the most common integration mistake.

During a Secret Rotation​

When you rotate a signing secret, the previous secret keeps working for 24 hours so you can deploy the new one without dropping deliveries. During that window each request carries two v1 values - one for each valid secret:

X-PlaySafe-Signature: t=1757372400,v1=5e1b6a...c04f,v1=91d7c2...8ab3

Treat the request as authentic if any v1 value matches. The examples below already do this. Code that reads only the first v1 will start rejecting valid deliveries mid-rotation.

Why the Timestamp Matters​

Without the timestamp check, someone who captured a single valid request could replay it at any point in the future and it would still verify. Rejecting requests outside a five-minute window bounds that risk. Because the timestamp is part of the signed payload, an attacker cannot change it without invalidating the signature.

If you see legitimate requests failing the timestamp check, your server clock has drifted - run NTP.

Example: Node.js​

import crypto from "node:crypto"

const TOLERANCE_SECONDS = 300

export function verifyPlaySafeSignature(rawBody, signatureHeader, secret) {
let timestamp = null
const signatures = []

for (const segment of signatureHeader.split(",")) {
const [key, value] = segment.split("=")
if (key === "t") timestamp = value
else if (key === "v1") signatures.push(value)
}

if (!timestamp || signatures.length === 0) return false

const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp))
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false

const expectedBuffer = Buffer.from(
crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex")
)

return signatures.some((candidate) => {
// Compare BYTE lengths, not string lengths - timingSafeEqual throws on a mismatch, and a
// multi-byte character makes the two differ even when the strings are the same length.
const candidateBuffer = Buffer.from(candidate)

return (
candidateBuffer.length === expectedBuffer.length &&
crypto.timingSafeEqual(candidateBuffer, expectedBuffer)
)
})
}

Wiring it up in Express - note express.raw, which preserves the body bytes:

import express from "express"

const app = express()

app.post(
"/webhooks/playsafe",
express.raw({ type: "application/json" }),
(req, res) => {
const isValid = verifyPlaySafeSignature(
req.body.toString("utf8"),
req.get("X-PlaySafe-Signature") ?? "",
process.env.PLAYSAFE_WEBHOOK_SECRET
)

if (!isValid) return res.status(401).json({ error: "Invalid signature" })

const event = JSON.parse(req.body.toString("utf8"))
// Queue the work; acknowledge immediately.
res.status(200).json({})
}
)

Example: Python​

import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_playsafe_signature(raw_body: bytes, signature_header: str, secret: str) -> bool:
timestamp = None
signatures = []

for segment in signature_header.split(","):
key, _, value = segment.partition("=")
if key == "t":
timestamp = value
elif key == "v1":
signatures.append(value)

if timestamp is None or not signatures:
return False

try:
age = abs(time.time() - int(timestamp))
except ValueError:
return False

if age > TOLERANCE_SECONDS:
return False

expected = hmac.new(
secret.encode("utf-8"),
f"{timestamp}.".encode("utf-8") + raw_body,
hashlib.sha256,
).hexdigest().encode("ascii")

# Compare bytes: compare_digest raises TypeError on non-ASCII str, and the header is
# attacker-controlled, so comparing strings turns a bad signature into a 500.
return any(
hmac.compare_digest(candidate.encode("utf-8", "replace"), expected)
for candidate in signatures
)

With Flask, request.get_data() gives you the raw body:

import os

from flask import Flask, request, jsonify

app = Flask(__name__)


@app.post("/webhooks/playsafe")
def playsafe_webhook():
is_valid = verify_playsafe_signature(
request.get_data(),
request.headers.get("X-PlaySafe-Signature", ""),
os.environ["PLAYSAFE_WEBHOOK_SECRET"],
)

if not is_valid:
return jsonify(error="Invalid signature"), 401

event = request.get_json()
# Queue the work; acknowledge immediately.
return jsonify({}), 200

Example: C# / .NET​

using System.Security.Cryptography;
using System.Text;

public static class PlaySafeWebhook
{
private const int ToleranceSeconds = 300;

public static bool VerifySignature(string rawBody, string signatureHeader, string secret)
{
string? timestamp = null;
var signatures = new List<string>();

foreach (var segment in signatureHeader.Split(','))
{
var pair = segment.Split('=', 2);
if (pair.Length != 2) continue;

if (pair[0] == "t") timestamp = pair[1];
else if (pair[0] == "v1") signatures.Add(pair[1]);
}

if (timestamp is null || signatures.Count == 0) return false;
if (!long.TryParse(timestamp, out var sentAt)) return false;

var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
if (Math.Abs(now - sentAt) > ToleranceSeconds) return false;

using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{rawBody}"));
var expected = Convert.ToHexString(hash).ToLowerInvariant();

return signatures.Any(candidate =>
CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(candidate),
Encoding.UTF8.GetBytes(expected)));
}
}

In an ASP.NET Core minimal API, read the body before model binding consumes it:

app.MapPost("/webhooks/playsafe", async (HttpRequest request) =>
{
using var reader = new StreamReader(request.Body);
var rawBody = await reader.ReadToEndAsync();

var signatureHeader = request.Headers["X-PlaySafe-Signature"].ToString();
var secret = Environment.GetEnvironmentVariable("PLAYSAFE_WEBHOOK_SECRET")!;

if (!PlaySafeWebhook.VerifySignature(rawBody, signatureHeader, secret))
return Results.Json(new { error = "Invalid signature" }, statusCode: 401);

// Queue the work; acknowledge immediately.
return Results.Json(new { });
});

Troubleshooting​

SymptomLikely cause
Signature never matchesThe body was parsed and re-serialized. Sign the raw bytes.
Worked, then failed after a rotationOnly the first v1 value is being checked. Check all of them.
Intermittent timestamp failuresServer clock drift. Run NTP.
Everything fails after changing environmentsSigning secrets are per webhook URL. Your staging endpoint has a different secret from production.
Dashboard shows failures, handler looks fineCheck you return a 2xx within 10 seconds - slower replies are recorded as failed.

Rotating a Signing Secret​

Rotate a secret if it may have been exposed, or on whatever schedule your security policy sets.

  1. In the dashboard, open Webhooks and choose Rotate secret on the webhook URL.
  2. Copy the new secret and deploy it to your backend.
  3. The previous secret stops being accepted 24 hours after the rotation.

Both secrets are valid during that window, so there is no need to coordinate a cutover or take your endpoint offline.

You cannot rotate again while a grace window is open. Rotating twice in quick succession would discard the secret you are still deploying, so the dashboard asks you to wait until the window closes.

By integrating webhooks, you can create a seamless connection between PlaySafe's automated moderation actions and your game's backend systems.