---
title: Webhooks
nav: Webhooks
order: 4
---
# 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
```json
{
  "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.refunded` carries the same `order_id`; its `event_id` is `<order_id>:refund`.
- `probe: true` is 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.

:::tabs
```js Node
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);
}
```
```python Python
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)
```
```go Go
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))
}
```
```csharp C#
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 `2xx` counts as delivered, and redirects are not followed.
- Your endpoint can be down and come back: deliveries resume, and your `order_id` dedupe makes them land exactly once in your game.
- Studio → **Integration → Health** shows the last acknowledgement, failures in the last 24 h, and dead letters.
