Guides
Webhooks
Let GameCoin call your server when an order is delivered, refunded or disputed, and check the signature of every call.
On this page
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 |
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:
{
"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 of the API, exactly what GET /orders/{orderId} returns. For wallet.credited, data says which player received what:
{
"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).
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:
- Read the raw body, before any JSON parsing: a parsed and re-serialized body can differ by one character.
- Read
tand everyv1from theGameCoin-Signatureheader. - Refuse the call if
tis more than 5 minutes away from your clock: it protects you from a replayed call. - Compute the signature of
t + "." + bodyand compare it with eachv1, in constant time.
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);
});
}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)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'] ?? '';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
}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;
}
}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:
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
idtwice. - Success is a
2xxanswer within 10 seconds. Answer quickly, then do the heavy work after. Redirections are not followed: a3xxis 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.statusand 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 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.