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
-
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.
-
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;
} -
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-Signatureheader 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, storemuteExpiryDate = endDate). - Log the event to your internal monitoring or analytics systems.
- Notify administrators or community managers.
- Update the player's status in your game database (e.g., set
- Respond with a
2xxstatus 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 retriedPlaySafe 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
| Element | Meaning |
|---|---|
t | Unix timestamp, in seconds, of when PlaySafe sent the request |
v1 | Hex-encoded HMAC-SHA256 signature |
To verify a request:
- Read the raw request body as a string, before any JSON parsing.
- Build the signed payload by joining the timestamp and the raw body with a period:
`${t}.${rawBody}`. - Compute an HMAC-SHA256 of that string using your signing secret as the key, hex-encoded.
- Compare your result against each
v1value using a constant-time comparison. - Reject the request if
tis more than five minutes from your own clock.
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
| Symptom | Likely cause |
|---|---|
| Signature never matches | The body was parsed and re-serialized. Sign the raw bytes. |
| Worked, then failed after a rotation | Only the first v1 value is being checked. Check all of them. |
| Intermittent timestamp failures | Server clock drift. Run NTP. |
| Everything fails after changing environments | Signing secrets are per webhook URL. Your staging endpoint has a different secret from production. |
| Dashboard shows failures, handler looks fine | Check 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.
- In the dashboard, open Webhooks and choose Rotate secret on the webhook URL.
- Copy the new secret and deploy it to your backend.
- 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.