Search documentation

Find pages, sections, and content across all docs.

WavedashDocs

Webhooks

Receive a signed receipt on your backend for every purchase and refund

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:

EnvironmentReceives
ProductionReal purchases from players.
PlaytestSimulated 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:

EventSent when
purchase.completedA player buys the content, from your in-game paywall, your game's store page, or a gift code.
purchase.refundedA purchase is refunded.

The full payload, with every claim, is in the types reference as PurchaseReceiptPayload.

Delivery

  • Respond with any 2xx within 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 refunded purchaseIds, so a late purchase.completed for 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:

CheckExpected
SignatureVerifies against a key in the JWKS
Header typwavedash-purchase+jwt, so no other Wavedash token passes as a receipt
isshttps://auth.wavedash.com
audYour game's ID, the game_id in wavedash.toml
envPRODUCTION 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 same receiptJwt to 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.