The events
| Event | When, and what it says |
|---|---|
| sale | A copy is bought or given as a gift: the game, what was paid (dollars), and whether it was a gift. Never who bought it. |
| purchase | A player buys inside a game (in-game purchases): the game, what was paid (dollars), the player’s id for the game, the payment’s id, whether it was a test (amount 0, no payment), and what it was: a product (key, kind, name, qty) or a charge (its id, name, and your ref). Your server gives it from this. |
| refund | Something sold is refunded: the game and how much went back (dollars). When Player Two refunds it (a player can’t refund an add-on or an in-game purchase themselves), it also has by "player-two" and the payment’s id, and, for an in-game purchase, what it was, as in purchase: a product (key, kind, name, qty) or a charge (its id, name and your ref), with the player’s id for the game; an add-on has its addon name. Player Two takes back what it holds (the copy, the add-on, the item, a consumable’s count, while none of it is used); your server takes back what it gave for a charge, and should re-read what the player owns. |
| review | A review is written or changed: the game, its stars, whether it recommends the game, its words, and its writer's handle (reviews are public). |
| wishlist | The game is put on a wishlist: how many players have it there now. Never who. |
| build | A new build of the game is live (pushed, uploaded, rolled back to, or from a GitHub commit): its key and version (what its developer called it, or its number in the game's builds), where it came from, its files and size. |
| follow | A player follows your studio: how many follow it now. Never who. |
Every event has event and at (when, ISO 8601), and a game (its slug, title and address) except follow, which has your studio. A test event comes when you press Send a test.
The request
POST https://your-service.com/player-two
content-type: application/json
user-agent: PlayerTwo-Webhooks/1
x-p2-event: sale
x-p2-delivery: 6a1f0d0e-5b8a-4f3c-9d2e-1c7b8a9e0f12
x-p2-timestamp: 1793102400
x-p2-signature: t=1793102400,v1=5d0c6e7a...
{
"event": "sale",
"at": "2026-10-27T12:00:00.000Z",
"game": { "slug": "your-game", "title": "Your Game", "url": "https://playertwo.lol/game/your-game" },
"amount": 4.99,
"gift": false
}{
"event": "purchase",
"at": "2026-11-05T13:20:00.000Z",
"game": { "slug": "your-game", "title": "Your Game", "url": "https://playertwo.lol/game/your-game" },
"amount": 4.99,
"charge": { "id": "4b8f2c1e-7a3d-4e9b-8c2f-1d6e5a9b0c3d", "name": "5,000 coins", "ref": "order-8812" },
"player": "Xk3q9V2mA7cT1pLw8eR4sZ",
"payment": "9c2e7f4a-1b3d-4a8e-9f6c-2d5b8e1a7c4f",
"test": false
}x-p2-deliveryis the delivery’s own id: a retry has the same one, so handle each id once.- Answer with any 2xx within ten seconds. Redirects aren’t followed.
- Webhooks go to public https addresses only, on the standard port.
Checking the signature
x-p2-signature is t=<seconds>,v1=<hex>: an HMAC-SHA256 of <seconds>.<the raw body> with the webhook’s secret (it starts p2whsec_; your studio shows it). Check it on the raw body before parsing it, and turn away anything older than a few minutes.
// Node: the raw body, as it arrived (before any JSON parsing), and the webhook's secret from your studio
import { createHmac, timingSafeEqual } from "node:crypto";
export function fromPlayerTwo(raw, header, secret) {
const [t, v1] = header.split(",").map((p) => p.split("=")[1]);
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false; // older than five minutes: a replay
const want = createHmac("sha256", secret).update(`${t}.${raw}`).digest("hex");
return v1.length === want.length && timingSafeEqual(Buffer.from(v1), Buffer.from(want));
}Retries
A delivery that isn’t answered with a 2xx is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours, then given up. A webhook that fails twenty times running is turned off, and you’re told; turn it back on in your studio once it’s fixed. Your studio shows each webhook’s last deliveries and what they were answered.
Discord
Give a Discord channel’s webhook address (Server Settings, Integrations, Webhooks, then Copy Webhook URL) and each event arrives as a message in plain words, such as “Someone bought Your Game ($4.99).”, with nothing in between to run.