Sending events
Three endpoints put data into Gemifier.
| Endpoint | What it does |
|---|---|
POST /v1/events | Records what a player did. |
PUT /v1/attributes | Sets what is true about a player. |
POST /v1/players | Creates a player before they have done anything. |
Missions, streaks, points, leaderboards and who can buy what are all worked out from these. All three take the environment from the API key rather than from the body, and the first two create the player on first contact.
Call these from your server. A project API key is full write access to its environment, so it never belongs in a browser, a mobile app or a game client. See Authentication.
What has to exist first
An event names a definition by key. Define your events and attributes in the dashboard, inside the project and environment you are about to write to.
You can also switch discovery on for an environment, and then a key nobody has defined is created from the first occurrence that carries it: the type is the JSON kind, and a discovered property is optional. Once the key exists the type holds either way, discovery or not. Discovery is for finding the shape of your data while you build, not for running on.
No endpoint lists your keys back, so keep the dashboard open while you wire up the first integration.
Sending events
One request carries up to 1,000 events. A single event is a batch of one, and there is no other way to send it.
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",
"properties": { "course_id": "spanish_a1", "score": 91 }
},
{
"playerId": "player-42",
"event": "app_opened",
"occurredAt": "2026-08-20T09:20:04Z"
}
]
}'| Field | Type | Required | Meaning |
|---|---|---|---|
playerId | string | yes | Your own ID for the player. At most 200 characters. |
event | string | yes | The event's key, lowercase snake_case. |
occurredAt | string | yes | When it happened, not when you send it. ISO 8601 with an offset. |
playerTimeZoneId | string | no | The player's IANA zone. See below. |
idempotencyKey | string | no | At most 200 characters. See Idempotency keys. |
properties | object | no | What the occurrence carries, checked against the event's properties. |
Batch whenever you can. Fifty events in one request cost little more than one on its own, and sending them one at a time is the slowest way to use this endpoint.
A 200 does not mean everything was accepted
Items are independent. Each is written or refused on its own, and reported in results in the order
you sent them.
{
"accepted": 1,
"rejected": 1,
"results": [
{
"index": 0,
"accepted": true,
"eventId": "0198f1c4-6b2f-7c31-9a0e-2f8d5a1b7c40",
"errors": []
},
{
"index": 1,
"accepted": false,
"eventId": null,
"errors": [
{ "code": "event.not_found", "message": "Event 'app_opened' was not found.", "meta": null }
]
}
]
}Check rejected, or the per-item accepted. The response is only non-2xx when the batch itself is
wrong:
| Code | Status | What happened |
|---|---|---|
event_batch.empty | 400 | No items. |
event_batch.too_large | 400 | Over 1,000 items. |
event_batch.concurrent_update | 409 | Other requests kept creating the same players at the same instant. Nothing was written. Send it again. |
Sending from several of your own workers at once, and retries that overlap the request they retry,
are expected and are sorted out for you. The 409 is the rare case left over.
Properties
A flat map of key to value. Each key has to be defined on that event, and each value's JSON type has
to match: a string for Text, a number for Number, true or false for Boolean, and an ISO 8601
string for Date and time. A property marked required has to be there.
Send what a rule will need at the moment you send the event. Nothing adds a property to an event afterwards.
A number keeps 8 decimal places and up to 20 digits before the point. Anything wider is refused with
event.property_value_out_of_range, so send an identifier as a string.
The player's time zone
playerTimeZoneId overwrites the player's stored zone every time you send it, not only at creation.
That zone decides which calendar day an event falls in for a streak, and which occurrence of a
recurring mission the player is in. A device zone sent on every event moves those boundaries with
the device, so send it once and deliberately, or leave it out and let the project's zone apply.
It has to be an IANA zone spelled the way IANA spells it, or you get 400 player.time_zone_invalid.
occurredAt cannot be in the future
More than five minutes ahead of now is refused with event.occurred_at_in_future. A future
timestamp moves the player's total immediately while no rule can see it yet, which reads as Gemifier
being broken and is not.
The past is fine, which is what makes a backfill ordinary ingest.
Setting attributes
An attribute is a fact about the player: their tier, their country, the plan they are on. Setting one replaces what was there.
curl -X PUT https://api.gemifier.io/v1/attributes \
-H "Authorization: Bearer gem_sbox_..." \
-H "Content-Type: application/json" \
-d '{
"playerId": "player-42",
"attributes": { "vip_tier": "gold", "lifetime_spend": 1240.5 },
"playerTimeZoneId": "Europe/Madrid"
}'| Field | Type | Required | Meaning |
|---|---|---|---|
playerId | string | yes | Your own ID for the player. |
attributes | object | yes | Attribute key to value. |
playerTimeZoneId | string | no | Same rules as on an event. |
occurredAt | string | no | When the values became true. Now when left out. |
The value's JSON type has to match the attribute's type. "7" on a Number attribute is a mismatch,
not a conversion.
All or nothing. One unknown key writes none of them, so a request either sets everything it names or nothing.
Read them back with GET /v1/attributes?playerId=player-42, which returns the same key to value
map. An attribute the player has no value for is left out rather than sent as null, and a player
this environment has never seen comes back empty rather than as a 404.
Creating a player
Worth doing at sign-up, so their time zone is right before their first event and their day ends at midnight where they are.
curl -X POST https://api.gemifier.io/v1/players \
-H "Authorization: Bearer gem_sbox_..." \
-H "Content-Type: application/json" \
-d '{ "playerId": "player-42", "timeZoneId": "Europe/Madrid" }'{ "playerId": "player-42", "created": true, "timeZoneId": "Europe/Madrid" }You do not have to. A player also appears the first time you send an event for them, set an
attribute, or ask for their missions. Calling this twice is not an error: the second call reports
created: false and updates the zone if you sent one, so a retry after a timeout is safe.
Idempotency keys
An idempotency key is a field in the body, never a header. An Idempotency-Key header is
ignored and the write happens twice.
On an event the key belongs to one player and one event key, so the same key on a different event, or for a different player, is a separate write. A key that matches something already recorded replays it: the item comes back accepted, with the original event's id, and nothing new is written.
Choose a key that identifies the occasion in your own system, not the attempt:
lesson-88f1c0 # your own lesson-completion row id
deposit-2026-08-20-4471 # your own deposit reference
2026-08-20T09:14:22Z # bad: a retry with a new clock reading writes twice
a fresh uuid per attempt # bad: never matches, so it does nothing at allRetrying a partly accepted batch re-sends the accepted items too. With keys they replay, without them they apply twice. See Idempotency.
Backfilling
A backfill is ordinary ingest with old timestamps. Four things to know:
- A running total moves whatever
occurredAtsays. A backfilled event lands on today's total. - A rule that counts a stretch of time sees a backfilled event only when its timestamp falls inside a period that is still open. It does not reopen one that has closed.
- History is not replayed. A mission that would have finished last March finishes today, carrying today's timestamps.
- Order does not matter, so load it in whatever order suits you. Load it before you switch on the missions that read it.
There is no limit on requests per second, and there is one on how many you have in flight at the
same time: 16, with 8 more allowed to wait. Beyond that you get 429 with a Retry-After header.
It belongs to your organization, so a backfill takes slots from your live traffic. One request
carrying 1,000 events takes one slot, which is the whole argument for batching. See
Errors.
What sending does not do
- It does not start a mission. An event advances missions the player is already in. Reading
their missions with
POST /v1/missionsis what enters them. - It does not re-check who is eligible. An attribute that makes a player eligible changes nothing until their missions are read again.
- It does not return what happened next. The response says what was written. Read the balance, the feed or the streaks separately, and a reward arrives as a webhook.