# Webhooks

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

Source: https://docs.wavedash.com/api/webhooks

Webhooks tell your backend about every purchase and refund of your game's Paid Content, whether it's a [durable](/sdk/paid-content/durables) or a [consumable](/sdk/paid-content/consumables). 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](/api/purchases).
- **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:

```http
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](/sdk/types#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 `purchaseId`s, 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`](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](/sdk/types#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](/api/purchases).
- **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](https://expressjs.com) and [`jose`](https://github.com/panva/jose). It verifies the receipt, grants the consumable once per `purchaseId`, then sends the same `receiptJwt` to the [fulfill endpoint](/api/purchases):

```typescript
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](/sdk/players#verifying-the-jwt-on-your-backend). 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](/sdk/paid-content/consumables#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.
