Custom webhook

Send orders, refunds, chargebacks and subscriptions from your own store or backend, signed with X-Analyse-Signature.

Updated September 15, 20264 min read

Sell through your own store or backend? Analyse gives the game an endpoint and a signing secret. Your server posts JSON events to the endpoint, signed with the secret, and Analyse records them like any other store.

Custom webhooks work for Minecraft and Roblox games. They connect to one game at a time.

1. Create the endpoint

  1. Open the game's Settings, Integration, choose Connect and pick Custom store.
  2. Choose Create endpoint.
  3. Copy the endpoint URL and the secret. The secret is shown once; store it on your server, for example as ANALYSE_WEBHOOK_SECRET.

Lost the secret? Choose New secret from the store menu. The old secret stops working immediately.

2. Sign each request

Every request needs an X-Analyse-Signature header:

HTTP
POST /api/webhooks/<id> HTTP/1.1
Content-Type: application/json
X-Analyse-Signature: t=1757923200000,v1=5f2b7c...e91a
  • t is the current Unix time in milliseconds.
  • v1 is the lowercase hex HMAC-SHA256 of the string <t>.<body>, using your secret as the key.
  • <body> is the exact raw JSON you send. Sign the string, then send that same string; do not re-serialise it.

Requests signed more than 5 minutes from Analyse's clock (either way) are rejected, so a captured request cannot be replayed later.

Node.js

JavaScript
import crypto from "node:crypto";

export async function sendToAnalyse(event) {
  const body = JSON.stringify(event);
  const t = Date.now();
  const v1 = crypto.createHmac("sha256", process.env.ANALYSE_WEBHOOK_SECRET).update(`${t}.${body}`).digest("hex");

  const res = await fetch(process.env.ANALYSE_WEBHOOK_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-Analyse-Signature": `t=${t},v1=${v1}` },
    body,
  });
  if (res.status >= 500) throw new Error("Analyse is unavailable, retry later");
  return res.json();
}

Python

Python
import hashlib, hmac, json, os, time, urllib.request

def send_to_analyse(event: dict) -> None:
    body = json.dumps(event, separators=(",", ":"))
    t = str(int(time.time() * 1000))
    v1 = hmac.new(os.environ["ANALYSE_WEBHOOK_SECRET"].encode(), f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
    req = urllib.request.Request(
        os.environ["ANALYSE_WEBHOOK_URL"],
        data=body.encode(),
        headers={"Content-Type": "application/json", "X-Analyse-Signature": f"t={t},v1={v1}"},
        method="POST",
    )
    urllib.request.urlopen(req)

Shell

Shell
BODY='{"id":"evt_test_1","type":"test","data":{}}'
T=$(($(date +%s) * 1000))
V1=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "$ANALYSE_WEBHOOK_SECRET" | sed 's/^.* //')
curl -X POST "$ANALYSE_WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -H "X-Analyse-Signature: t=$T,v1=$V1" \
  -d "$BODY"

3. Send a test

Send an event with "type": "test". It checks the URL and signature and records nothing. The setup dialog's delivery log shows every request with its status and error.

Event envelope

Every request is one event:

JSON
{
  "id": "evt_01J8Z3",
  "type": "order.completed",
  "data": {}
}
FieldTypeNotes
idstring, 1 to 128Unique per event. Sending the same id again is acknowledged as a duplicate and not recorded twice
typestringtest, order.completed, order.refunded, order.chargeback or subscription.updated
dataobjectAn order or a subscription, depending on type

Unknown extra fields are ignored.

Orders

Use order.completed for a paid order, order.refunded for a refund and order.chargeback for a chargeback.

JSON
{
  "id": "evt_1001",
  "type": "order.completed",
  "data": {
    "order_id": "ord_5512",
    "player": { "id": "069a79f4-44e9-4726-a5be-fca90e38aaf5", "name": "Notch" },
    "currency": "EUR",
    "purchased_at": "2026-09-15T14:03:00Z",
    "coupon": "SUMMER10",
    "creator_code": "stevebuilds",
    "items": [
      { "id": "vip", "name": "VIP Rank", "quantity": 1, "amount": 999, "discount": 100 },
      { "id": "crate_key", "name": "Crate Key", "quantity": 3, "amount": 450 }
    ]
  }
}
FieldTypeNotes
order_idstring, requiredYour order id, 1 to 128 characters
player.idstringMinecraft UUID with dashes, or Roblox user id. Leave out player for purchases with no player
player.namestringOptional, up to 64 characters
currencystring, required3-letter code such as USD or EUR
purchased_atstring, requiredISO 8601 date and time, with offset or Z
couponstringOptional, up to 64 characters
creator_codestringOptional. Matched to a campaign's creator code
itemsarray, required1 to 100 items
items[].idstring, requiredYour package or product id
items[].namestringOptional, up to 128 characters
items[].quantityinteger1 to 10000, default 1
items[].amountinteger, requiredWhat was paid for this line, all units together, in minor units (cents)
items[].discountintegerDiscount on this line in minor units, default 0

Each item is recorded as its own purchase, keyed by order_id and the item id.

Subscriptions

Send subscription.updated whenever a subscription starts, changes or ends.

JSON
{
  "id": "evt_2001",
  "type": "subscription.updated",
  "data": {
    "subscription_id": "sub_881",
    "player": { "id": "069a79f4-44e9-4726-a5be-fca90e38aaf5" },
    "item": { "id": "vip_monthly", "name": "VIP Monthly" },
    "status": "active",
    "interval": "month",
    "amount": 499,
    "currency": "USD",
    "started_at": "2026-09-01T10:00:00Z",
    "current_period_end": "2026-10-01T10:00:00Z"
  }
}
FieldTypeNotes
subscription_idstring, required1 to 128 characters
playerobjectAs for orders
itemobjectOptional { "id", "name" }
statusstring, requiredactive, cancelled, expired or paused
intervalstring, requiredweek, month, quarter or year
amountinteger, requiredPrice per interval in minor units
currencystring, required3-letter code
started_atstring, requiredISO 8601
current_period_end, cancelled_at, ended_atstring or nullOptional, ISO 8601

Send each renewal's payment as its own order.completed too, so it counts as revenue.

Responses

Errors come back as {"success":false,"error":"<message>"}; the table shows the message.

StatusBody or errorMeaning
200{"success":true,"rows":2}Recorded
200{"success":true,"duplicate":true}This id was already processed; nothing changed
401Invalid signatureWrong secret, wrong signed string or a timestamp outside 5 minutes
401Webhook secret not setThe endpoint has no secret yet
404Not foundThe endpoint URL is wrong or the store was disconnected
422Invalid payload: <field>: <problem>The event failed validation. Fix it; retrying the same body will fail again
500Processing failedSomething went wrong on our side. Retry later with the same id

Retry on 500 and network errors. Do not retry 401, 404 or 422 without changing the request.