Sending events

Three endpoints put data into Gemifier.

EndpointWhat it does
POST /v1/eventsRecords what a player did.
PUT /v1/attributesSets what is true about a player.
POST /v1/playersCreates 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.

Send a batch
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"
      }
    ]
  }'
FieldTypeRequiredMeaning
playerIdstringyesYour own ID for the player. At most 200 characters.
eventstringyesThe event's key, lowercase snake_case.
occurredAtstringyesWhen it happened, not when you send it. ISO 8601 with an offset.
playerTimeZoneIdstringnoThe player's IANA zone. See below.
idempotencyKeystringnoAt most 200 characters. See Idempotency keys.
propertiesobjectnoWhat 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.

200 OK
{
  "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:

CodeStatusWhat happened
event_batch.empty400No items.
event_batch.too_large400Over 1,000 items.
event_batch.concurrent_update409Other 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.

Set attributes
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"
  }'
FieldTypeRequiredMeaning
playerIdstringyesYour own ID for the player.
attributesobjectyesAttribute key to value.
playerTimeZoneIdstringnoSame rules as on an event.
occurredAtstringnoWhen 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.

Create a player
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" }'
200 OK
{ "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:

Keys that work, and keys that do not
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 all

Retrying 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 occurredAt says. 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/missions is 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.

Next

On this page