Webhooks: order.paid and order.refunded
Appside delivers every paid and refunded order to your Fulfillment URL as a signed HTTPS POST. This is the only place you should grant or revoke items.
Payload
{
"type": "order.paid",
"event_id": "TEST-6f1c…",
"mode": "test",
"tenant": "orbit",
"order_id": "TEST-6f1c…",
"player_id": "player-123",
"probe": false,
"order": { "id": "TEST-6f1c…", "sku": "voyager", "credits": 1400, "price": 799, "currency": "USD", "version": 3 }
}
order.refundedcarries the sameorder_id; itsevent_idis<order_id>:refund.probe: trueis the Studio Send test event: acknowledge it, grant nothing.
Headers
| Header | Meaning |
|---|---|
x-appside-timestamp | Unix seconds when signed |
x-appside-signature | hex HMAC-SHA256 of "<timestamp>." + rawBody with your webhook secret |
idempotency-key | the event_id; dedupe on it |
Verify the signature
Reject if the timestamp is more than 5 minutes off, then compare in constant time.
import { createHmac, timingSafeEqual } from 'node:crypto';
export function verify(raw, h, secret) {
const ts = h['x-appside-timestamp'], sig = h['x-appside-signature'] || '';
if (!/^\d{10}$/.test(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false;
const want = createHmac('sha256', secret).update(`${ts}.`).update(raw).digest();
const got = Buffer.from(sig, 'hex');
return got.length === want.length && timingSafeEqual(got, want);
}import hmac, hashlib, time
def verify(raw: bytes, h, secret: str) -> bool:
ts, sig = h.get("x-appside-timestamp", ""), h.get("x-appside-signature", "")
if not ts.isdigit() or abs(time.time() - int(ts)) > 300: return False
want = hmac.new(secret.encode(), f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(want, sig)func Verify(raw []byte, ts, sig, secret string) bool {
t, err := strconv.ParseInt(ts, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-t)) > 300 { return false }
m := hmac.New(sha256.New, []byte(secret)); m.Write([]byte(ts + ".")); m.Write(raw)
return hmac.Equal([]byte(hex.EncodeToString(m.Sum(nil))), []byte(sig))
}static bool Verify(byte[] raw, string ts, string sig, string secret) {
if (!long.TryParse(ts, out var t) || Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - t) > 300) return false;
using var h = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var want = Convert.ToHexString(h.ComputeHash(Encoding.UTF8.GetBytes(ts + ".").Concat(raw).ToArray())).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(want), Encoding.ASCII.GetBytes(sig));
}
Delivery guarantees
- The order and its webhook are written in one transaction: a paid order is never left without a delivery.
- Retries with exponential backoff (1 s, capped at 1 h) for 72 hours. Only
2xxcounts as delivered, and redirects are not followed. - Your endpoint can be down and come back: deliveries resume, and your
order_iddedupe makes them land exactly once in your game. - Studio → Integration → Health shows the last acknowledgement, failures in the last 24 h, and dead letters.