Building a mission
This guide builds one mission end to end: 250 points and a catalog item when a player finishes five lessons.
It takes both halves of the product. You define and author in the dashboard, then run against the Public API. No endpoint creates an event, a catalog item or a mission, and none reads a definition back, so the first two steps are dashboard work.
Step 1: define what you count and what you pay
In the dashboard, with the right project and environment selected (Sandbox while you build):
- Events. Create an event with key
lesson_completed. The key is lowercase snake_case and is what you will name when you send it. - Catalog. Create a catalog item with key
bonus_lesson_packand value type Number. A catalog item is a name for something your own system hands out. Gemifier never hands it over, it only asks you to.
Keys belong to an environment. A key that exists in Sandbox does not exist in Live until you create it there too, and an API key writes only to its own environment.
Step 2: author the mission
On Missions, create a mission with key finish_five_lessons, one objective and two rewards. The
objective is a rule: lesson_completed happened at least 5 times. No endpoint
accepts a rule, so the dashboard is the only place it is set, and it reaches your integration only
in reverse, as the progress readings in Step 5.
The settings that matter for the rest of this page:
| Setting | Value here | Effect |
|---|---|---|
| Opt-in | Automatic | The player takes part as soon as the mission is returned to them. Manual needs an accept call. |
| Objective order | Any order | Objectives can be met in any order. In order requires each one's predecessors first. |
| Where it appears | The feed | It shows in the player's missions. Collection only hides it from the feed. |
| Repeats | No | A one-time mission. Its occurrenceNumber is always 0. |
| Who can take part | Everyone | Restrict it, and you choose whether a player who does not qualify sees it locked or not at all. |
A mission needs at least one objective and at least one reward. Its key, objectives, order, opt-in and recurrence are fixed once it is created, so changing an objective means creating a new mission and switching the old one off.
Give it two rewards: 250 points, and 3 of bonus_lesson_pack. The two settle differently, and that
difference shapes everything below. Points are internal and Gemifier moves the balance itself. A
catalog item is external: Gemifier records that it is owed and asks you for it over a webhook. A
reward is copied when it is granted, so editing the mission later does not rewrite what a player
already earned.
Step 3: enter the player
POST /v1/missions returns the player's missions, and
returning one to a player is what enters them into it. It creates the player if they are new,
and starts every mission they newly qualify for.
curl -X POST https://api.gemifier.io/v1/missions \
-H "Authorization: Bearer gem_sbox_..." \
-H "Content-Type: application/json" \
-d '{
"playerId": "player-42",
"includeCompleted": false,
"languageCode": "en"
}'This is the step most integrations miss. An event sent for a player whose missions have never been read advances nothing, and no error says so. Call this when a player first appears, and again whenever you show them their missions.
On this API a POST means something changed, which is why the mission reads are POST while the
streak and shop reads are GET. Do not treat it as a safe read to hammer in a loop.
The body takes playerId (required), take to cap how many come back, includeCompleted
(false by default) and languageCode.
Step 4: send the events
curl -X POST https://api.gemifier.io/v1/events \
-H "Authorization: Bearer gem_sbox_..." \
-H "Content-Type: application/json" \
-d '{
"events": [
{
"playerId": "player-42",
"event": "lesson_completed",
"occurredAt": "2026-08-20T09:14:22Z",
"idempotencyKey": "lesson-88f1c0"
}
]
}'An event moves only the missions whose objectives name that key. A mission built on
deposit_made does not move on a lesson_completed event, and a key that appears only in who can
take part wakes nothing, because that is settled when the missions are read. See
Sending events for the full field list.
If opt-in is Manual, the mission sits at Available and does not advance until the player accepts:
curl -X POST https://api.gemifier.io/v1/missions/finish_five_lessons/accept \
-H "Authorization: Bearer gem_sbox_..." \
-H "Content-Type: application/json" \
-d '{ "playerId": "player-42" }'That endpoint returns 204 with no body. Accepting twice
does nothing, and accepting an automatic mission is harmless. Accepting one the player was never
offered is 404 mission.not_locked_in. Nothing undoes an accept.
Step 5: read the progress back
Call POST /v1/missions again.
[
{
"missionKey": "finish_five_lessons",
"name": "Finish five lessons",
"description": "Finish five lessons to earn a bonus pack.",
"images": {},
"status": "InProgress",
"optInMode": "Automatic",
"isAccepted": true,
"occurrenceNumber": 0,
"objectives": [
{
"objectiveId": "0198f1c4-31aa-7b02-8f4c-1d9e5a7c3b60",
"description": "Finish five lessons",
"order": 0,
"isSatisfied": false,
"progress": {
"kind": "TowardTarget",
"actual": { "type": "number", "value": 3 },
"operator": "GreaterThanOrEqualTo",
"target": { "type": "number", "value": 5 },
"fraction": 0.6
}
}
],
"rewards": [
{ "kind": "Points", "pointsAmount": 250, "catalogItemKey": null, "catalogItemValue": null },
{
"kind": "CatalogItem",
"pointsAmount": null,
"catalogItemKey": "bonus_lesson_pack",
"catalogItemValue": { "type": "number", "value": 3 }
}
],
"startedAtUtc": "2026-08-20T09:02:10.442Z",
"acceptedAtUtc": "2026-08-20T09:02:10.442Z",
"completedAtUtc": null,
"availableUntilUtc": null
}
]status is one of Available, InProgress, Completed or Locked. Available is a manual
mission the player has not accepted. Locked is the only one they are not in: startedAtUtc is
null, no objective is satisfied, and every objective reports NotDisplayable.
The progress shape
Every objective carries a progress object with these five fields, and it is never null. An
objective with nothing to show reports kind: "NotDisplayable" rather than leaving it out, so a
client can never read "missing" as "zero".
| Field | Type | Meaning |
|---|---|---|
kind | enum | TowardTarget, WithinLimit, Binary or NotDisplayable. Switch on this, not on the shape of the values. |
actual | value or null | Where the player is. null when there is no reading, which is not the same as zero. |
operator | enum or null | What the reading is measured by. |
target | value or null | What it is measured against. |
fraction | number or null | A ratio from 0 to 1, only for TowardTarget and WithinLimit, and only when both values are known. Its absence is the signal not to draw a bar. |
actual and target are tagged: a type of string, number, boolean, dateTime or
collection, with value on the first four and items on a collection.
What each kind means on screen:
TowardTargetcounts up to a target. A filling bar is right here and only here.WithinLimitstays under a ceiling. It starts satisfied and is lost as the value rises, sofraction: 1means gone rather than done. Draw a draining budget.Binaryis a yes or a no.fractionis alwaysnull.NotDisplayablehas no single reading to show. OnlyisSatisfiedmeans anything.
isSatisfied is the authority, not progress. A satisfied objective stays satisfied even if the
value behind it falls back, so the two can disagree: a WithinLimit objective the player has since
broken reads isSatisfied: true with fraction: 1.
order and occurrenceNumber both count from 0. Add one before showing a number to a person.
Step 6: receive the completion
mission.completed reports the fact. It is sent only if an endpoint in that environment subscribes
to it.
{
"version": 1,
"event": "mission.completed",
"deliveryId": "0198f1c5-77ab-7d19-95e2-4c0b8e6f2a13",
"occurredAtUtc": "2026-08-20T09:31:07.118Z",
"data": {
"playerExternalId": "player-42",
"missionKey": "finish_five_lessons",
"occurrenceId": "0198f1c5-77ab-7d19-95e2-4c0b8e6f2a13",
"occurrenceNumber": 0,
"completedAtUtc": "2026-08-20T09:31:07.118Z"
}
}catalog_item.fulfillment_requested is the instruction. Its sourceId is the same value as the
completion's occurrenceId, which is how you join the two:
{
"version": 1,
"event": "catalog_item.fulfillment_requested",
"deliveryId": "0198f1c5-7a02-7e44-b6d1-8f2c0a5b93e7",
"occurredAtUtc": "2026-08-20T09:31:07.118Z",
"data": {
"playerExternalId": "player-42",
"catalogItemKey": "bonus_lesson_pack",
"valueType": "Number",
"value": 3,
"sourceType": "Mission",
"sourceId": "0198f1c5-77ab-7d19-95e2-4c0b8e6f2a13"
}
}A notification carries no amounts. mission.completed never names the points or the catalog item,
so there is nothing on it to credit. One completion paying three catalog items produces three
instructions sharing one sourceId, and one paying only points produces none. See
Events.
What to poll, and what to wait for
Sending is immediate. Everything worked out from it is not, and nothing reports how far behind it is. Usually it is seconds.
Poll the missions for progress and status. That read and the collection read are the only public reads of mission state. Poll when a screen is opened, not in a tight loop: both of them write.
Read the balance with GET /v1/points. Mission points land
at the same moment the completion does, so any read that sees status: "Completed" sees the new
balance too.
Wait for the webhook for a catalog item. There is nothing to poll: nothing reports whether a delivery was made, retried or given up on. If no endpoint is subscribed, nothing tells you the item was earned.
Do not poll for eligibility. A player becomes eligible the next time you call
POST /v1/missions for them, and not before.
Traps worth knowing before you go live
- A manual mission that is never accepted never advances, and shows as
Availablefor good. includeCompletedisfalseby default, so a finished mission disappears unless you ask for it.takecaps how many come back. Locked ones come after everything the player can act on.- A closed window stops new players joining, not the ones already in. A mission whose window has
closed is never offered to somebody new, and is not shown as locked either. A player who joined
before it closed still sees their row, with
availableUntilUtcin the past. - An event cannot be switched off while an active mission or streak uses it. The dashboard refuses and names what depends on it, so switch the mission off first.
- Sandbox and Live share nothing below the environment. Authoring a mission in Sandbox does not
create it in Live, and a
gem_sbox_key cannot see a Live mission.