Webhooks tell your backend about every purchase and refund of your game's Paid Content, whether it's a durable or a consumable. They arrive even when the game is closed, so your backend can keep its records up to date without the game in the loop.
- Consumables. Grant the purchase on your backend, then fulfill it over HTTP.
- Durables. Wavedash already unlocked the files, so there's nothing to fulfill. Use the webhook to keep your own records in sync, for example to know which players own an expansion.
Setting a webhook URL
Set a URL in Developer Portal → your game → Monetization → Webhooks. There's one URL per environment:
| Environment | Receives |
|---|---|
| Production | Real purchases from players. |
| Playtest | Simulated purchases from playtests and local wavedash dev testing. |
URLs must use https://. Leave an environment blank to turn its webhooks off.
The request
For every purchase and refund, Wavedash sends:
POST /webhooks/wavedash HTTP/1.1
Content-Type: application/json
User-Agent: Wavedash-Webhooks/1
{ "receiptJwt": "eyJhbGciOiJSUzI1NiIsImtpZCI6..." }The body carries only the signed receipt. Everything about the purchase is in its claims, and the event claim says what happened:
| Event | Sent when |
|---|---|
purchase.completed | A player buys the content, from your in-game paywall, your game's store page, or a gift code. |
purchase.refunded | A purchase is refunded. |
The full payload, with every claim, is in the types reference as PurchaseReceiptPayload.
Delivery
- Respond with any
2xxwithin 10 seconds. Anything else, a timeout, or a redirect counts as a failure. Redirects aren't followed. - Failed deliveries are retried eight times over a period of three days.
- Deliveries aren't ordered, and can repeat. A refund can arrive before the purchase it refunds, and a retry can land after a success you were too slow to acknowledge. Dedupe on
purchaseId, and remember refundedpurchaseIds, so a latepurchase.completedfor one doesn't grant it. - Every attempt is logged in the developer dashboard under Monetization → Webhook deliveries, with the request, your response, and a Resend button.
Verifying the receipt
Anyone can POST to a public URL, so verify every receipt before acting on it. A receipt is a JWT signed with RS256 by Wavedash's keys, published at https://auth.wavedash.com/.well-known/jwks.json. Reject it unless all of these hold:
| Check | Expected |
|---|---|
| Signature | Verifies against a key in the JWKS |
Header typ | wavedash-purchase+jwt, so no other Wavedash token passes as a receipt |
iss | https://auth.wavedash.com |
aud | Your game's ID, the game_id in wavedash.toml |
env | PRODUCTION on your production backend (PLAYTEST for simulated purchases) |
Receipts have no exp, so don't require one. Replaying a receipt is harmless as long as you act on each purchaseId only once. See PurchaseReceiptPayload for every claim in the payload.
Handling a webhook
Verify the receipt, then branch on event and ptype:
- Consumable purchase: grant it once per
purchaseId, then fulfill it by sending the samereceiptJwtto the fulfill endpoint. - Durable purchase: Wavedash already unlocked the files, so just record the ownership.
- Refund: undo whatever you recorded for that
purchaseId.
Respond 2xx within 10 seconds once you're done. If the fulfill call fails for any reason other than a refund, respond with an error so Wavedash retries the webhook.
A complete handler in Node.js with Express and jose. It verifies the receipt, grants the consumable once per purchaseId, then sends the same receiptJwt to the fulfill endpoint:
import express from "express";
import { createRemoteJWKSet, jwtVerify } from "jose";
const JWKS = createRemoteJWKSet(
new URL("https://auth.wavedash.com/.well-known/jwks.json")
);
const WAVEDASH_GAME_ID = process.env.WAVEDASH_GAME_ID!; // game_id in wavedash.toml
// What each consumable grants, keyed by content identifier.
const COINS_PER_PACK: Record<string, number> = { "coins-100": 100, "coins-500": 500 };
interface PurchaseReceipt {
sub: string; // Wavedash user ID of the buyer
env: "PRODUCTION" | "PLAYTEST";
event: "purchase.completed" | "purchase.refunded";
purchaseId: string;
ptype: "CONSUMABLE" | "DURABLE";
contentIdentifier: string;
}
async function verifyReceipt(receiptJwt: string): Promise<PurchaseReceipt> {
const { payload } = await jwtVerify(receiptJwt, JWKS, {
issuer: "https://auth.wavedash.com",
audience: WAVEDASH_GAME_ID,
typ: "wavedash-purchase+jwt",
});
const receipt = payload as unknown as PurchaseReceipt;
if (receipt.env !== "PRODUCTION") throw new Error("Not a production purchase");
return receipt;
}
async function fulfillPurchase(purchaseId: string, receiptJwt: string) {
return fetch(`https://api.wavedash.com/api/purchases/${purchaseId}/fulfill`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ receiptJwt }),
});
}
const app = express();
app.use(express.json());
app.post("/webhooks/wavedash", async (req, res) => {
// 1. Verify. Anyone can POST here, so only a valid receipt counts.
const receiptJwt: string = req.body.receiptJwt;
let receipt: PurchaseReceipt;
try {
receipt = await verifyReceipt(receiptJwt);
} catch {
return res.status(401).end();
}
// Durables are fulfilled by Wavedash, and refunds can't be fulfilled.
// Acknowledge them after updating your own records.
if (receipt.event !== "purchase.completed" || receipt.ptype !== "CONSUMABLE") {
return res.status(200).end();
}
// 2. Grant once per purchaseId. Webhooks can repeat, and the game may
// forward the same purchase from its PURCHASE_COMPLETED handler.
await db.transaction(async (tx) => {
const isNew = await tx.recordPurchaseIfNew(receipt.purchaseId, receipt.sub);
if (isNew) await tx.addCoins(receipt.sub, COINS_PER_PACK[receipt.contentIdentifier]);
});
// 3. Fulfill with the same receipt, so the SDK stops redelivering it.
const fulfill = await fulfillPurchase(receipt.purchaseId, receiptJwt);
if (fulfill.ok) {
const { status } = await fulfill.json(); // "FULFILLED" or "ALREADY_FULFILLED"
console.log(`Fulfilled ${receipt.purchaseId}: ${status}`);
} else if (fulfill.status === 404) {
// Refunded before you fulfilled it. Take back the grant.
await db.removePurchase(receipt.purchaseId, receipt.sub);
} else {
// Let Wavedash retry the webhook. The grant above won't repeat.
return res.status(500).end();
}
// 4. Acknowledge within 10 seconds, or Wavedash retries.
res.status(200).end();
});
app.listen(3000);db stands in for your database. The only requirement is that recording the purchaseId and adding the item happen together, so a repeated webhook can't grant twice.
The receipt's sub is the buyer's Wavedash user ID, the same ID as sub in their user JWT. A webhook can arrive before that player has ever talked to your backend, so create the player record if you haven't seen them yet. See Matching purchases to players.
For a durable, a refund also takes the content away: the player loses access to its files, and isEntitled returns false. For a consumable, you decide whether to take back what you granted.
Testing
Set the Playtest URL and buy something in a playtest or under wavedash dev. The paywall simulates the purchase without charging anyone, and the webhook's receipt carries env: "PLAYTEST". Use Resend in the delivery log to replay a delivery while you work on your handler.