Custom webhook
Send orders, refunds, chargebacks and subscriptions from your own store or backend, signed with X-Analyse-Signature.
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
- Open the game's Settings, Integration, choose Connect and pick Custom store.
- Choose Create endpoint.
- 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:
POST /api/webhooks/<id> HTTP/1.1
Content-Type: application/json
X-Analyse-Signature: t=1757923200000,v1=5f2b7c...e91atis the current Unix time in milliseconds.v1is 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
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
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
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:
{
"id": "evt_01J8Z3",
"type": "order.completed",
"data": {}
}| Field | Type | Notes |
|---|---|---|
id | string, 1 to 128 | Unique per event. Sending the same id again is acknowledged as a duplicate and not recorded twice |
type | string | test, order.completed, order.refunded, order.chargeback or subscription.updated |
data | object | An 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.
{
"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 }
]
}
}| Field | Type | Notes |
|---|---|---|
order_id | string, required | Your order id, 1 to 128 characters |
player.id | string | Minecraft UUID with dashes, or Roblox user id. Leave out player for purchases with no player |
player.name | string | Optional, up to 64 characters |
currency | string, required | 3-letter code such as USD or EUR |
purchased_at | string, required | ISO 8601 date and time, with offset or Z |
coupon | string | Optional, up to 64 characters |
creator_code | string | Optional. Matched to a campaign's creator code |
items | array, required | 1 to 100 items |
items[].id | string, required | Your package or product id |
items[].name | string | Optional, up to 128 characters |
items[].quantity | integer | 1 to 10000, default 1 |
items[].amount | integer, required | What was paid for this line, all units together, in minor units (cents) |
items[].discount | integer | Discount 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.
{
"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"
}
}| Field | Type | Notes |
|---|---|---|
subscription_id | string, required | 1 to 128 characters |
player | object | As for orders |
item | object | Optional { "id", "name" } |
status | string, required | active, cancelled, expired or paused |
interval | string, required | week, month, quarter or year |
amount | integer, required | Price per interval in minor units |
currency | string, required | 3-letter code |
started_at | string, required | ISO 8601 |
current_period_end, cancelled_at, ended_at | string or null | Optional, 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.
| Status | Body or error | Meaning |
|---|---|---|
200 | {"success":true,"rows":2} | Recorded |
200 | {"success":true,"duplicate":true} | This id was already processed; nothing changed |
401 | Invalid signature | Wrong secret, wrong signed string or a timestamp outside 5 minutes |
401 | Webhook secret not set | The endpoint has no secret yet |
404 | Not found | The endpoint URL is wrong or the store was disconnected |
422 | Invalid payload: <field>: <problem> | The event failed validation. Fix it; retrying the same body will fail again |
500 | Processing failed | Something 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.