Developer docs

Webhooks & API

Send WishlistLab events to your own tools. When an audit finishes, a score moves or wishlist numbers come in, we POST signed JSON to your URL. Agencies use it to update client dashboards, Slack channels and reports without copying anything by hand.

Webhooks: all paid plansAPI & Zapier: AgencyHMAC-SHA256 signed

Quick start

  1. 1Add an endpointIn My WishlistLab → Webhooks & API, paste an https URL and pick the events you want.
  2. 2Copy the secretEach endpoint gets its own whsec_… secret. Use it to check every request really came from us.
  3. 3Send a testClick Send test. You'll see the status code and timing in the delivery log straight away.
Solo · Studio · AgencyWebhooksEndpoints, all four events, signing secrets, test events, retries and the delivery log.
AgencyAPI & ZapierAPI keys, the REST API (run audits, list results, REST hooks) and the WishlistLab Zapier app.

Each event only carries your own account's data.

Events

audit.completed

An audit of one of your games finished (site, re-audit, weekly Pro re-audit or the API).

reaudit.score_changed

A new audit of a game scored differently from its previous audit.

wishlist.daily_imported

Steamworks wishlist numbers were imported (CSV upload or the daily API pull).

marketplace.booking_updated

A marketplace booking you're part of changed (confirmed, paid, delivered, completed and so on).

The examples below use Lanternfall, an invented game (it has no Steam app ID, so appid is 0 and steam_url is null; real events always have both). Every request has the same envelope. data depends on the event type. test is true for test events from your account page. Use id to ignore an event you've already handled: a retry sends the same id again.

HeaderWhat it is
WishlistLab-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256> (see below)
WishlistLab-EventThe event type, e.g. audit.completed
WishlistLab-Event-IdSame as id in the body
WishlistLab-DeliveryThis delivery's id (changes per endpoint)
WishlistLab-TimestampSame as t in the signature

audit.completed

Score out of 100, the 7 category scores with each issue and fix (in live events), the top fixes and the report link. source is website, reaudit, weekly or api.

{
  "id": "evt_8kH2xQ9mLp3vT6wZ",
  "type": "audit.completed",
  "created": "2026-10-18T09:00:04.000Z",
  "api_version": "2026-10-18",
  "test": false,
  "data": {
    "audit": {
      "id": "example",
      "url": "https://wishlistlab.io/#examples",
      "score": 72,
      "max_score": 100,
      "categories": [
        { "name": "Capsule art", "score": 15, "max": 20 },
        { "name": "Short description", "score": 11, "max": 15 },
        { "name": "Trailer", "score": 10, "max": 15 },
        { "name": "Screenshots", "score": 11, "max": 15 },
        { "name": "Tags/genre fit", "score": 10, "max": 15 },
        { "name": "About section", "score": 7, "max": 10 },
        { "name": "Social proof", "score": 8, "max": 10 }
      ],
      "top_fixes": [
        "Open the short description with “cozy roguelite lighthouse keeper” so the genre lands in five words.",
        "Cut the 12-second logo intro so the trailer opens on lantern-dodging gameplay.",
        "Move the boss-fight screenshot into the first four and add the “Roguelite” and “Cozy” tags."
      ],
      "suggested_tags": [
        "Roguelite",
        "Cozy",
        "Pixel Graphics"
      ],
      "source": "weekly",
      "created_at": "2026-10-18T09:00:00.000Z"
    },
    "game": { "appid": 0, "name": "Lanternfall", "steam_url": null, "header_image": "https://wishlistlab.io/art/lanternfall.webp", "example": true }
  }
}

reaudit.score_changed

Sent alongside audit.completed when the new score differs from the previous audit of the same game on your account. change is the difference in points.

{
  "id": "evt_8kH2xQ9mLp3vT6wZ",
  "type": "reaudit.score_changed",
  "created": "2026-10-18T09:00:04.000Z",
  "api_version": "2026-10-18",
  "test": false,
  "data": {
    "audit": {
      "id": "example",
      "url": "https://wishlistlab.io/#examples",
      "score": 72,
      "max_score": 100,
      "categories": [
        { "name": "Capsule art", "score": 15, "max": 20 },
        { "name": "Short description", "score": 11, "max": 15 },
        { "name": "Trailer", "score": 10, "max": 15 },
        { "name": "Screenshots", "score": 11, "max": 15 },
        { "name": "Tags/genre fit", "score": 10, "max": 15 },
        { "name": "About section", "score": 7, "max": 10 },
        { "name": "Social proof", "score": 8, "max": 10 }
      ],
      "top_fixes": [
        "Open the short description with “cozy roguelite lighthouse keeper” so the genre lands in five words.",
        "Cut the 12-second logo intro so the trailer opens on lantern-dodging gameplay.",
        "Move the boss-fight screenshot into the first four and add the “Roguelite” and “Cozy” tags."
      ],
      "suggested_tags": [
        "Roguelite",
        "Cozy",
        "Pixel Graphics"
      ],
      "source": "weekly",
      "created_at": "2026-10-18T09:00:00.000Z"
    },
    "game": { "appid": 0, "name": "Lanternfall", "steam_url": null, "header_image": "https://wishlistlab.io/art/lanternfall.webp", "example": true },
    "previous": { "id": "example-prev", "url": "https://wishlistlab.io/#examples", "score": 66, "created_at": "2026-10-11T09:00:00.000Z" },
    "change": 6,
    "category_changes": [
      { "name": "Trailer", "was": 6, "now": 10, "max": 15 },
      { "name": "Short description", "was": 9, "now": 11, "max": 15 }
    ]
  }
}

wishlist.daily_imported

Your private Steamworks numbers: totals for the imported range and up to the 90 most recent days. Only sent to your own endpoints.

{
  "id": "evt_8kH2xQ9mLp3vT6wZ",
  "type": "wishlist.daily_imported",
  "created": "2026-10-18T09:00:04.000Z",
  "api_version": "2026-10-18",
  "test": false,
  "data": {
    "game": { "appid": 0, "name": "Lanternfall", "steam_url": null, "header_image": "https://wishlistlab.io/art/lanternfall.webp", "example": true },
    "source": "csv",
    "days_imported": 2,
    "first_date": "2026-10-16",
    "last_date": "2026-10-17",
    "totals": { "adds": 312, "deletes": 41, "purchases": 9, "gifts": 1, "net": 261 },
    "days": [
      { "date": "2026-10-16", "adds": 140, "deletes": 19, "purchases": 4, "gifts": 0, "net": 117 },
      { "date": "2026-10-17", "adds": 172, "deletes": 22, "purchases": 5, "gifts": 1, "net": 144 }
    ]
  }
}

marketplace.booking_updated

Sent to the client and to the provider, each with their own role. It never includes the other side's contact details.

{
  "id": "evt_8kH2xQ9mLp3vT6wZ",
  "type": "marketplace.booking_updated",
  "created": "2026-10-18T09:00:04.000Z",
  "api_version": "2026-10-18",
  "test": false,
  "data": {
    "booking": { "id": "cm0example0booking", "title": "Next Fest trailer", "status": "in_progress", "amount_pence": 50000, "currency": "GBP", "deadline": "2026-11-01T00:00:00.000Z", "provider_name": "Example Trailer Co", "category": "trailer", "url": "https://wishlistlab.io/account#bookings" },
    "change": { "type": "delivered", "actor": "freelancer", "at": "2026-10-18T09:00:00.000Z" },
    "role": "client"
  }
}

Checking signatures

We sign `${t}.${raw body}` with HMAC-SHA256 using the endpoint's secret and send it as v1. Compute the same value from the raw request body and compare in constant time. Reject requests where t is more than 5 minutes from your clock, so an old request can't be replayed.

Node.js (Express)

import crypto from "node:crypto";
import express from "express";

const app = express();
const SECRET = process.env.WISHLISTLAB_WEBHOOK_SECRET; // whsec_…

// Use the raw body: re-serialised JSON won't match the signature.
app.post("/wishlistlab-webhook", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("WishlistLab-Signature") || "";      // t=1760778004,v1=5f2b…
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return res.sendStatus(400);
  const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${req.body}`).digest("hex");
  const ok = parts.v1 && parts.v1.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!ok) return res.sendStatus(400);

  const event = JSON.parse(req.body);
  // Answer quickly, then do the work. Use event.id to ignore duplicates.
  res.sendStatus(200);
  if (event.type === "reaudit.score_changed") console.log(event.data.game.name, event.data.change); // e.g. "Lanternfall" 6
});
app.listen(3000);

Python (Flask)

import hashlib, hmac, json, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["WISHLISTLAB_WEBHOOK_SECRET"].encode()

@app.post("/wishlistlab-webhook")
def hook():
    raw = request.get_data()  # raw bytes, not request.json
    parts = dict(p.split("=", 1) for p in request.headers.get("WishlistLab-Signature", "").split(","))
    t = int(parts.get("t", 0))
    if abs(time.time() - t) > 300:
        abort(400)
    expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, parts.get("v1", "")):
        abort(400)
    event = json.loads(raw)
    return "", 200

Responses, retries and switching off

Answer with any 2xx status within 10 seconds. Do slow work after you've replied. Anything else (a timeout, a 4xx or 5xx, or a redirect, which we don't follow) counts as a failure and we try again:

AttemptWhen
1Straight away
21 min after attempt 1
35 min after attempt 2
430 min after attempt 3
52 h after attempt 4
68 h after attempt 5
724 h after attempt 6

That's 7 attempts over about a day and a half. After 15 failed attempts in a row with no success in between, we switch the endpoint off and email you. Fix it, then switch it back on in your account. If your server answers 410 Gone, we stop sending to that endpoint (Zapier subscriptions are removed). Every delivery is listed in your account for 30 days, and you can resend any that failed. Test events are sent once and never count towards switching an endpoint off.

Endpoints must use https on port 443 and be reachable from the internet. Each account can have up to 20 endpoints.

REST API

Agency plan API keys and every /api/v1 endpoint are part of the Agency plan. On other plans the API answers 403 with the code agency_plan_required. Webhooks don't need a key.

Create a key in My WishlistLab → Webhooks & API and send it as Authorization: Bearer wl_live_… (or an X-API-Key header). We only store a fingerprint of the key, so it's shown once. Revoke it at any time. Errors look like {"error": {"code": "…", "message": "…"}}.

MethodPathWhat it does
GET/api/v1/meWho the key belongs to and the plan. Good for testing a key.
POST/api/v1/auditsRun an audit. Body: steam_url, and optional callback_url. Without a callback it waits for the result (usually 20 to 60 seconds); with one it answers 202 and POSTs the result to callback_url.
GET/api/v1/auditsYour account's audits, newest first. Query: limit (1 to 100), appid.
GET/api/v1/audits/score-changesScore changes between audits of the same game, newest first. Query: limit.
POST/api/v1/hooksSubscribe a URL to an event (REST hooks, used by Zapier). Body: target_url, event. Returns the id and signing secret.
GET/api/v1/hooksList your webhook subscriptions.
DELETE/api/v1/hooks/:idUnsubscribe.

Run an audit

curl https://wishlistlab.io/api/v1/audits \
  -H "Authorization: Bearer wl_live_…" \
  -H "Content-Type: application/json" \
  -d '{"steam_url": "https://store.steampowered.com/app/YOUR_APP_ID/"}'

The response has the same shape as audit.completed data, plus status and cached. An unchanged page audited in the last 24 hours comes back instantly. Each account can run up to 100 audits in any 24 hours through the API (weekly re-audits count too).

Subscribe a URL

curl https://wishlistlab.io/api/v1/hooks \
  -H "Authorization: Bearer wl_live_…" \
  -H "Content-Type: application/json" \
  -d '{"target_url": "https://example.com/wishlistlab-webhook", "event": "audit.completed"}'

Zapier

Our Zapier app (Agency plan) has two triggers, New audit and Score changed, and one action, Run audit. It connects with your API key. Until it's listed in Zapier, and on Solo or Studio, you can use Webhooks by Zapier → Catch Hook: paste the Catch Hook URL as an endpoint in your account and pick your events. That only uses webhooks, so it works on every paid plan.

Ideas an agency can set up in a few minutes: post every score change for a client's game to their Slack channel, add each audit as a row in a client report sheet, or audit every new Steam page a client sends through a form.

Hook WishlistLab into your stack

Webhooks come with every paid plan, and Agency adds the API and Zapier. Your data only goes to the URLs you add.

How we handle data sent through webhooks: privacy notice.