Receiving webhooks
Gemifier posts an instruction to a URL you registered, and your system hands the item to the player. That is the only way a catalog item reaches anyone.
Four rules shape the receiver:
- Verify before you parse. The signature covers the raw bytes. (Verifying signatures)
- De-duplicate on
X-Gemifier-Delivery. Delivery is at-least-once. (Retries) - Answer within 10 seconds, or the attempt counts as failed.
- Never answer
4xxfor a problem that might pass. Any4xxexcept408and429ends the delivery on the first refusal, and the player loses the reward.
So the receiver does almost nothing: authenticate, write the delivery down once, return 2xx. The
real work happens after the response.
1. Have something to fulfil
None of this is created over the API. In a Sandbox project you need a catalog
item on the Catalog screen, with a key such as free_spins and value type Number, an event
such as lesson_completed on the Events screen, a mission on the Missions screen that pays that
catalog item, and a Sandbox API key on the API keys screen, which is gem_sbox_ followed by 64
hex characters and is shown once.
2. Register the endpoint and ping it
On the Webhooks screen, add your public URL and subscribe it to Reward to deliver, which is
CatalogItemFulfillmentRequested on the wire. Copy the signing secret out of the dialog now. It
starts whsec_ and is never shown again. Read it from your secret store, not your repository.
One endpoint per environment receives fulfilment. The three notifications go to as many as you like. See Webhooks.
Then press Ping, before you write any logic. It sends one real, signed delivery and reports back
what your server said. It is the cheapest way to find out that your proxy strips the X-Gemifier-
headers, that your framework already consumed the body, or that your HMAC is over parsed JSON rather
than bytes. A ping carries "event": "ping", which is not one of the four you can subscribe to, so
your dispatch has to acknowledge an unrecognised event rather than reject it.
3. The receiver
Raw body in, verification, one insert, 2xx out. verifyGemifierSignature is the function from
Verifying signatures.
const express = require("express")
const { Pool } = require("pg")
const { verifyGemifierSignature } = require("./gemifier-signature")
const app = express()
const pool = new Pool()
const secret = process.env.GEMIFIER_WEBHOOK_SECRET // whsec_...
// Gemifier signs and sends the timestamp but enforces no freshness window, so
// this number is yours. It is stamped per attempt, not per event, so it only
// has to cover clock skew and time in flight.
const TOLERANCE_SECONDS = 300
app.post(
"/gemifier",
express.raw({ type: "application/json" }),
async (request, response) => {
const timestamp = request.get("X-Gemifier-Timestamp")
const signature = request.get("X-Gemifier-Signature")
const deliveryId = request.get("X-Gemifier-Delivery")
if (!verifyGemifierSignature(request.body, timestamp, signature, secret)) {
// Forged or misconfigured. 401 is permanent, which is correct: a
// byte-identical retry would fail the same way.
return response.sendStatus(401)
}
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SECONDS) {
return response.sendStatus(401)
}
const envelope = JSON.parse(request.body.toString("utf8"))
if (envelope.version !== 1) {
// A shape you were not built for. Say you are not ready rather than
// refusing permanently.
return response.sendStatus(503)
}
// The delivery id is stable across every retry of one occasion and
// different between two occasions. A unique constraint on it is what makes
// this insert idempotent.
const claimed = await pool.query(
`INSERT INTO gemifier_deliveries (delivery_id, event, occurred_at_utc, payload)
VALUES ($1, $2, $3, $4)
ON CONFLICT (delivery_id) DO NOTHING
RETURNING delivery_id`,
[deliveryId, envelope.event, envelope.occurredAtUtc, envelope.data],
)
if (claimed.rowCount === 0) {
// Already accepted. Answering 2xx again is what stops the retries.
return response.sendStatus(200)
}
return response.sendStatus(202)
},
)
app.listen(3000)CREATE TABLE gemifier_deliveries (
delivery_id uuid PRIMARY KEY,
event text NOT NULL,
occurred_at_utc timestamptz NOT NULL,
payload jsonb NOT NULL,
processed_at timestamptz
);Note what the receiver does not do: call your bonus system, talk to a payment provider, or wait for anything. Ten seconds is the whole budget, and a slow dependency inside the request turns into a failed attempt and then a retry of work you may already have started.
4. Fulfil out of band
Read unprocessed rows and dispatch on event. Field names are in Events.
async function processOne(row) {
switch (row.event) {
case "catalog_item.fulfillment_requested":
// The instruction. row.payload.value is ABSENT, not null, when the
// catalog item carries no value, so read it defensively.
await fulfil({
playerId: row.payload.playerExternalId,
item: row.payload.catalogItemKey,
valueType: row.payload.valueType, // "Number" | "String" | "Boolean"
value: row.payload.value,
idempotencyKey: row.delivery_id,
})
break
case "mission.completed":
// A notification. Carries no amounts, so there is nothing to credit.
// occurrenceNumber counts from 0: a one-time mission always sends 0.
await recordCompletion(
row.payload.playerExternalId,
row.payload.missionKey,
row.payload.occurrenceNumber + 1,
)
break
case "streak.milestone_reached":
await recordMilestone(
row.payload.playerExternalId,
row.payload.streakKey,
row.payload.threshold,
)
break
default:
// "ping", or an event type added after you deployed. Both are fine.
break
}
await markProcessed(row.delivery_id)
}Pass the delivery id into your own fulfilment system as its idempotency key. You have already
answered 2xx, which Gemifier reads as a durable promise, so if your processing crashes halfway the
only thing that can finish the work is your own retry, not another delivery.
5. Choosing what to return
| Situation | Return | Why |
|---|---|---|
| Accepted and stored | 200 or 202 | Any 2xx ends the delivery. |
| Already seen this delivery id | 200 | The at-least-once contract expects this. |
| Signature does not verify | 401 | Permanent, and correctly so. A retry is byte-identical. |
| Your dependency is down | 503 | Transient. Keeps the delivery alive. |
| Mid-deploy, the route does not exist | 503, never 404 | A 404 abandons the delivery immediately. |
Envelope version you do not understand | 503 | Buys you a deploy window. |
6. Prove it end to end in Sandbox
curl -X POST https://api.gemifier.io/v1/missions \
-H "Authorization: Bearer gem_sbox_..." \
-H "Content-Type: application/json" \
-d '{ "playerId": "webhook-test-1" }'Reading a player's missions is what creates the player and puts them into the ones they qualify for. Until that has happened, no event moves anything.
curl -X POST https://api.gemifier.io/v1/events \
-H "Authorization: Bearer gem_sbox_..." \
-H "Content-Type: application/json" \
-d '{
"events": [
{
"playerId": "webhook-test-1",
"event": "lesson_completed",
"occurredAt": "2026-08-20T09:14:22Z"
}
]
}'occurredAt is required and has no default, so an item sent without one is refused with
event.occurred_at_missing. Check rejected in the response: a 200 does not mean every item was
accepted.
curl -X POST https://api.gemifier.io/v1/missions \
-H "Authorization: Bearer gem_sbox_..." \
-H "Content-Type: application/json" \
-d '{ "playerId": "webhook-test-1" }'Everything after sending is worked out afterwards, so allow a few seconds between step 2 and step 3 rather than reading straight away.
Your endpoint should now have received one catalog_item.fulfillment_requested with
"sourceType": "Mission" and a sourceId equal to the occurrenceId on the mission.completed
notification, if you subscribed to that too.
If nothing arrives, check in this order: the mission actually completed (step 3 shows it), the endpoint is active and subscribed to Reward to deliver, and the Webhook deliveries panel shows something waiting or abandoned.
7. Before you go live
A Live endpoint is a separate registration with a separate secret. There is no
promotion step, and a Sandbox endpoint never receives Live traffic. Key your secrets by environment:
a receiver that verifies Live deliveries against the Sandbox secret returns 401, which is
permanent, which loses real rewards on the first attempt. Going live is the rest of
the list.