Skip to content
Skip the menu

Guides

Webhooks

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

View as Markdown

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:

FieldRule
Addresshttps 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.
EventsAt least one of the events below.
StateActive 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

EventWhen
order.fulfilledAn order is paid and delivered: the currency and the items are credited.
wallet.creditedThe same delivery, when it added currency to a wallet. It follows order.fulfilled.
order.refundedAn order is refunded, from your server, from the dashboard or from the payment provider. Its coins and items are taken back.
order.chargebackThe 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:

HeaderValue
Content-Typeapplication/json; charset=utf-8
GameCoin-Signaturet=<unix seconds>,v1=<hex signature>, see below
GameCoin-Event-IdThe id of the event, the same as id in the body.
GameCoin-Event-TypeThe type of the event.

Every body has the same envelope:

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 of the API, exactly what GET /orders/{orderId} returns. For wallet.credited, data says which player received what:

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).

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.
LangageLanguage
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);
  });
}

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:

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 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.