One POST to send activity in. JSON endpoints to read it back out.
Your backend sends one authenticated HTTPS request when something happens. The same backend reads missions, streaks, points and the shop back out as JSON. No SDK, no agent, nothing required inbound to your network.
Four steps from an empty account to a mission that completes.
Mint a key and declare what you want to track in the dashboard, then send an event and read the result from your code. The last two steps are a single POST each.
Get your Sandbox keys
Every project has two environments, Sandbox and Live. Mint a key inside Sandbox. It is shown once, because we keep only a SHA-256 hash. If you lose it, revoke it and mint another.
Declare a metric
Declare what you want to move: a key such as purchase_amount, a kind of
Number or Currency, and any properties you want to filter on. An
undeclared key is refused at ingest.
Send your first event
One POST, one header. The first event carrying an unknown
externalPlayerId creates that customer.
Read the result back
Build a mission that uses the metric and send the event again. Fetch the result with
POST /v1/missions. That response is the shape your front end renders.
- An outbound HTTPS client. Whatever your stack already uses.
- One header.
Authorization: Bearer gem_sbox_… - Your own id for the customer. We never issue one.
- Nothing inbound. No SDK, no agent.
Two write endpoints: one for things that happen, one for facts about the person.
POST /v1/metric-events records something that happened, such as a purchase,
and adds its value to that customer's running total. POST /v1/attribute-events
records something true about the person right now, such as their tier, and the last write
wins. Both create the customer on first sight.
- Two timestamps. You send
occurredAtUtc. We record arrival separately, so a replayed backlog lands in the right day. - Currency is normalised into the environment's base currency at the rate in effect. The original amount, currency and rate are kept.
- Properties are validated against what you declared. A wrong type is refused.
- The customer is created on first activity. No registration call.
{ // required: your own id for the end user, never ours "externalPlayerId": "cust_8f2a41c", // required: a metric key you declared, snake_case "metricKey": "order_total", // required: the amount, before normalisation "value": 64.90, // required for a currency metric, omitted otherwise "currencyCode": "EUR", // optional: overrides the stored rate for this event "exchangeRate": 1.0850, // optional: makes a retry free "idempotencyKey": "ord_9f31c2", // required: when it happened, not when you sent it "occurredAtUtc": "2026-07-28T18:24:05Z", // optional: typed, and filterable inside conditions "properties": { "method": "card", "channel": "mobile-app", "is_first_of_day": true }, // optional: IANA zone, used for day boundaries "playerTimeZoneId": "Europe/Belgrade" }
curl -X POST https://api.gemifier.io/v1/metric-events \ -H "Authorization: Bearer $GEMIFIER_KEY" \ -H "Content-Type: application/json" \ -d '{ "externalPlayerId": "cust_8f2a41c", "metricKey": "order_total", "value": 64.90, "currencyCode": "EUR", "idempotencyKey": "ord_9f31c2", "occurredAtUtc": "2026-07-28T18:24:05Z", "properties": { "method": "card" } }' # the key decides Sandbox or Live. Swap the environment # variable, not the URL and not the body.
{ "id": "0198f4c2-91a7-7c40-8b12-6d0e3f5a2c19" } // the id of the recorded event, and nothing else. // a retry carrying the same idempotency key returns // this same id, see "retries and failures" below. // // evaluation happens after the response, and cannot // be lost once you are holding this id.
// facts about the person, not things they did { "externalPlayerId": "cust_8f2a41c", // required: an attribute key you declared "attributeKey": "tier", // exactly one value, matching the declared type: // stringValue, numberValue, booleanValue, dateTimeValue "stringValue": "gold", "idempotencyKey": "tier_2026_07_28", "occurredAtUtc": "2026-07-28T18:24:05Z" } // last write wins: an attribute is a current state, // not a running total, and eligibility rules read it. // same key model, same idempotency, same error shape.
| Field | Type | Required | Notes |
|---|---|---|---|
| externalPlayerId | string | yes | Your own id for the customer, up to 200 characters. An unknown value creates it. |
| metricKey | string | yes | A metric you declared, snake_case, active in this environment. |
| value | number | yes | The amount. For a Currency metric, the original amount before conversion. |
| currencyCode | string | conditional | Three letters. Required for a Currency metric, rejected on a Number metric. |
| exchangeRate | number | no | Overrides the stored rate. Only travels with a currencyCode. |
| idempotencyKey | string | no | Unique per metric. A retry returns the original event. |
| occurredAtUtc | timestamp | yes | When it happened, not when you sent it. |
| properties | object | no | Checked against the metric's definitions: String, Number, Boolean or DateTime. |
| playerTimeZoneId | string | no | An IANA zone such as Europe/Belgrade. It sets day boundaries. |
The endpoints behind a customer's progress screen.
Every POST changes something, and the GETs only read. A progress screen gets everything it needs as JSON on the same key you write with: the mission feed with progress per objective, the current and best streak, the points balance and ledger, and the shop.
| Endpoint | What comes back |
|---|---|
| POST /v1/missions | The mission feed: objectives, rewards and status. |
| POST /v1/mission-collections/{collectionKey} | One collection, in the order you curated it. |
| POST /v1/missions/{missionKey}/accept | Opts a customer into a mission that needs it. |
| GET /v1/streaks | The live run, the longest run, and when it ends. |
| GET /v1/points | The current balance. An unknown customer has zero, not a 404. |
| GET /v1/points/transactions | The ledger, newest first, with the cause of each move. |
| POST /v1/points/adjustments | A manual credit or debit, with a reason recorded. |
| GET /v1/marketplace-items | What this customer can see and buy right now. |
| GET /v1/marketplace-collections/{collectionKey} | One curated shop section. |
| POST /v1/marketplace-items/{key}/purchases | Buys one unit, spends the points and records a grant. |
A purchase passes six checks, in order. It stops at the first failure, so the error code tells you which one closed.
- Ingest, adjustments and purchases take an idempotency key. A retry returns the original. Nothing is counted twice.
- One error shape. RFC 9457
problem+json, with acodeyou switch on, every problem listed at once, and atraceId. - You issue what the customer receives. We record the grant against your catalog item. Money never touches us.
One key belongs to one environment for its whole life.
The key you send decides whether you reach Sandbox or Live. Nothing in the request body changes that. There is no environment parameter to get wrong, so a test key reaches Sandbox and never a real customer.
- Prefixed by environment.
gem_live_…orgem_sbox_…, so a key identifies itself. - Shown once, revocable at any moment. We keep a SHA-256 hash.
- People sign in separately. Servers hold a project key. Your colleagues use the dashboard. A leaked project key cannot edit a mission.
- Webhooks are opt-in. Register an HTTPS endpoint and we POST catalog grants, mission completions and streak milestones to it, signed with HMAC-SHA256. Delivery repeats, so de-duplicate on
deliveryId.
One event per request. Tell us early if your volume is high.
You get JSON, not a progress bar. Building the screen is real front-end work.
Each API serves an OpenAPI document. Generating a typed client is your build step.
Mission funnel, points in and out, active customers, streak outcomes and marketplace sales are all real, behind a person's sign-in. A project key reads one customer at a time.
Send one event and watch what it moves.
Bring the name of one thing you track and one campaign you want to launch. We will wire it up in a test account and complete it live on the call.