# Webhooks

> Let GameCoin call your server when an order is delivered, refunded or disputed, and check the signature of every call.

## What a webhook does

A webhook is a call that GameCoin makes **to your server**. You do not poll the API to find out that a player paid: when an order changes state, GameCoin sends an event to the address you declared, signed with a secret that only you and GameCoin know.

Use webhooks to credit what the game keeps on its own side (an unlock, a counter, an achievement), to refresh a wallet shown on screen, or to react to a refund.

> [!NOTE]
> The wallet and the inventory are already updated by GameCoin when the event reaches you. A webhook is a notification, not a request to credit: never grant coins because an event says so without first checking its signature.

## Declare an endpoint

In the dashboard (in French for now), open your game, then « Webhooks ». For each environment you can add an endpoint:

| Field | Rule |
|---|---|
| Address | `https` only. `http` is accepted towards `localhost` in the test environment, to develop at home. Addresses of private networks, `localhost` in live and internal names are refused. |
| Events | At least one of the events below. |
| State | Active or not. An inactive endpoint receives nothing. |

A game has **five endpoints at most**, both environments together. Only members with the developer role or above can create, change or delete an endpoint.

When you create an endpoint, the dashboard shows its **signing secret** (`whsec_gc_…`) once. Copy it into your server's configuration, never into the game. If you lose it, generate a new one with « Nouveau secret »: the previous secret stops working at once.

## Events

| Event | When |
|---|---|
| `order.fulfilled` | An order is paid and delivered: the currency and the items are credited. |
| `wallet.credited` | The same delivery, when it added currency to a wallet. It follows `order.fulfilled`. |
| `order.refunded` | An order is refunded, from your server, from the dashboard or from the payment provider. Its coins and items are taken back. |
| `order.chargeback` | The payer disputes the payment. Its coins and items are taken back. |

The test environment sends the events of simulated orders, the live environment those of real payments. An endpoint only receives the events of its own environment.

## The request

GameCoin sends a `POST` with a JSON body and these headers:

| Header | Value |
|---|---|
| `Content-Type` | `application/json; charset=utf-8` |
| `GameCoin-Signature` | `t=<unix seconds>,v1=<hex signature>`, see [below](#verify-the-signature) |
| `GameCoin-Event-Id` | The id of the event, the same as `id` in the body. |
| `GameCoin-Event-Type` | The type of the event. |

Every body has the same envelope:

```json title="order.fulfilled"
{
  "id": "evt_6ac419fbdbe02af6c99c3d47_ful",
  "type": "order.fulfilled",
  "createdAt": "2026-10-06T10:00:02.000Z",
  "env": "test",
  "data": {
    "id": "6ac419fbdbe02af6c99c3d47",
    "playerId": "6ac419fa60e877541898f38d",
    "status": "fulfilled",
    "pack": { "sku": "gems-500", "name": "Bag of gems" },
    "amountCents": 499,
    "currency": "EUR",
    "country": "BE",
    "vatRateBp": 2100,
    "vatCents": 87,
    "netCents": 412,
    "createdAt": "2026-10-06T10:00:00.000Z",
    "paidAt": "2026-10-06T10:00:01.000Z",
    "fulfilledAt": "2026-10-06T10:00:01.000Z",
    "refundedAt": null,
    "creatorCode": null
  }
}
```

For `order.*` events, `data` is the [Order object](/docs/api/objects) of the API, exactly what `GET /orders/{orderId}` returns. For `wallet.credited`, `data` says which player received what:

```json title="wallet.credited"
{
  "id": "evt_6ac419fbdbe02af6c99c3d47_cre",
  "type": "wallet.credited",
  "createdAt": "2026-10-06T10:00:02.000Z",
  "env": "test",
  "data": {
    "playerId": "6ac419fa60e877541898f38d",
    "orderId": "6ac419fbdbe02af6c99c3d47",
    "credits": [{ "currency": "gems", "paid": 500, "bonus": 50 }]
  }
}
```

The `id` of an order event is always the same for the same event: use it to process an event **once**, because GameCoin can send it again (see [Delivery and retries](#delivery-and-retries)).

## Verify the signature

The signature proves that GameCoin sent the call and that nobody changed the body. It is the HMAC-SHA256 of the text `<t>.<raw body>`, with your signing secret as the key, written in lowercase hexadecimal. It works like the signature of Stripe's webhooks.

To verify a call:

1. Read the **raw** body, before any JSON parsing: a parsed and re-serialized body can differ by one character.
2. Read `t` and every `v1` from the `GameCoin-Signature` header.
3. Refuse the call if `t` is more than 5 minutes away from your clock: it protects you from a replayed call.
4. Compute the signature of `t + "." + body` and compare it with each `v1`, in constant time.

<!-- tabs:start -->
```javascript tab="Node.js"
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyGameCoinSignature(rawBody, header, secret, toleranceSeconds = 300) {
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(",")) {
    const [key, value] = part.trim().split("=");
    if (key === "t") timestamp = Number(value);
    if (key === "v1" && value) signatures.push(value);
  }
  if (!Number.isInteger(timestamp) || signatures.length === 0) return false;
  if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;

  const expected = createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest();
  return signatures.some((hex) => {
    const given = Buffer.from(hex, "hex");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
}
```
```python tab="Python"
import hashlib
import hmac
import time


def verify_gamecoin_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    timestamp = None
    signatures = []
    for part in header.split(","):
        key, _, value = part.strip().partition("=")
        if key == "t" and value.isdigit():
            timestamp = int(value)
        elif key == "v1" and value:
            signatures.append(value)
    if timestamp is None or not signatures or abs(time.time() - timestamp) > tolerance:
        return False

    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, given) for given in signatures)
```
```php tab="PHP"
function verifyGameCoinSignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
        if ($key === 't' && ctype_digit($value)) {
            $timestamp = (int) $value;
        } elseif ($key === 'v1' && $value !== '') {
            $signatures[] = $value;
        }
    }
    if ($timestamp === null || $signatures === [] || abs(time() - $timestamp) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    foreach ($signatures as $given) {
        if (hash_equals($expected, $given)) {
            return true;
        }
    }
    return false;
}

// $rawBody = file_get_contents('php://input');
// $header = $_SERVER['HTTP_GAMECOIN_SIGNATURE'] ?? '';
```
```go tab="Go"
package gamecoinhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"strconv"
	"strings"
	"time"
)

// Verify checks the GameCoin-Signature header of a webhook call. rawBody is the body exactly as received.
func Verify(rawBody []byte, header, secret string, tolerance time.Duration) bool {
	var timestamp int64
	var signatures []string
	for _, part := range strings.Split(header, ",") {
		key, value, _ := strings.Cut(strings.TrimSpace(part), "=")
		switch key {
		case "t":
			if n, err := strconv.ParseInt(value, 10, 64); err == nil {
				timestamp = n
			}
		case "v1":
			if value != "" {
				signatures = append(signatures, value)
			}
		}
	}
	if timestamp == 0 || len(signatures) == 0 {
		return false
	}
	age := time.Since(time.Unix(timestamp, 0))
	if age < 0 {
		age = -age
	}
	if age > tolerance {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	fmt.Fprintf(mac, "%d.", timestamp)
	mac.Write(rawBody)
	expected := mac.Sum(nil)
	for _, candidate := range signatures {
		given, err := hex.DecodeString(candidate)
		if err == nil && hmac.Equal(given, expected) {
			return true
		}
	}
	return false
}
```
```csharp tab="C#"
using System.Security.Cryptography;
using System.Text;

public static class GameCoinWebhook
{
    public static bool Verify(byte[] rawBody, string header, string secret, int toleranceSeconds = 300)
    {
        long? timestamp = null;
        var signatures = new List<string>();
        foreach (var part in header.Split(','))
        {
            var pair = part.Trim().Split('=', 2);
            if (pair.Length != 2) continue;
            if (pair[0] == "t" && long.TryParse(pair[1], out var t)) timestamp = t;
            else if (pair[0] == "v1" && pair[1].Length > 0) signatures.Add(pair[1]);
        }
        if (timestamp is null || signatures.Count == 0) return false;
        if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp.Value) > toleranceSeconds) return false;

        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
        var signed = Encoding.UTF8.GetBytes($"{timestamp}.").Concat(rawBody).ToArray();
        var expected = hmac.ComputeHash(signed);
        foreach (var candidate in signatures)
        {
            try
            {
                if (CryptographicOperations.FixedTimeEquals(Convert.FromHexString(candidate), expected)) return true;
            }
            catch (FormatException)
            {
                // Not hexadecimal: this value cannot match.
            }
        }
        return false;
    }
}
```
<!-- tabs:end -->

> [!WARNING]
> Verify the signature on the **raw** body. With Express, read the body with `express.raw({ type: "application/json" })` on this route, not `express.json()`. Compare signatures in constant time, as above, and keep the secret out of your logs.

Here is a complete route in Node.js with Express. It answers `200` once the event is recorded, and `400` when the signature is wrong:

```javascript title="server.js"
import express from "express";
import { verifyGameCoinSignature } from "./verify.js";

const app = express();

app.post("/gamecoin/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  const header = req.get("GameCoin-Signature") ?? "";
  if (!verifyGameCoinSignature(req.body, header, process.env.GAMECOIN_WEBHOOK_SECRET)) {
    return res.sendStatus(400);
  }

  const event = JSON.parse(req.body.toString("utf8"));
  if (await alreadyProcessed(event.id)) return res.sendStatus(200); // a repeated event: nothing to do

  if (event.type === "order.fulfilled") {
    await unlockPurchase(event.data.playerId, event.data.pack.sku);
  }
  await markProcessed(event.id);
  res.sendStatus(200);
});
```

## Delivery and retries

- **At least once.** An event is written in the same step as the change of the order: if the order changed, the event exists, and if the change was cancelled, no event is sent. Because a call can be repeated, your handler must tolerate the same `id` twice.
- **Success is a `2xx`** answer within **10 seconds**. Answer quickly, then do the heavy work after. Redirections are not followed: a `3xx` is a failure.
- **Retries.** After a failure GameCoin tries again 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours later, then gives up on that event. The event stays in the log and you can send it again by hand.
- **Order.** Events are sent as soon as possible, in the order they were written, but a retry can make an older event arrive after a newer one. Do not rely on the order: `data.status` and the dates of the order tell you the current state.
- **A failing endpoint is disabled.** After **50 failures in a row** the endpoint is disabled and the dashboard warns you. Fix your server, send a test, then enable the endpoint again.
- **Secure destinations only.** GameCoin refuses to call a private or internal address, and checks the address that your domain name resolves to each time it sends.

The delivery log of each endpoint shows, for every event, the HTTP status, the duration, the number of attempts and the beginning of your answer. From there a developer can send an event again (the same `id`) or send a `webhook.test` event to check the signature code.

> [!TIP]
> The `webhook.test` event has the same envelope and the same signature as the others, with `"type": "webhook.test"`. Answer it like any other event.

## Allowed origins

A related setting lives in « Réglages »: the **allowed origins** of a game. They are the web pages of your game (`https://yourgame.example.com`) that may call the [client API](/docs/api/client) from a browser, and where the player is sent back after a payment. In the test environment an empty list accepts every origin; in the live environment an empty list refuses every browser.
