Server events API
Send your conversions from your backend with a secret key: schema, responses, errors, idempotency and limits.
Endpoint
https://firstmrr.app/api/v1/eventsAuthorization: Bearer sk_live_…A JSON body { "events": [...] } with 1 to 100 events, 64 KB at most. Each event is accepted or refused on its own: one invalid event never blocks the others.
curl https://firstmrr.app/api/v1/events \
-H "Authorization: Bearer $FIRSTMRR_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{"events":[{"type":"signup","event_id":"user_8412","visitor_id":"6f1c2b9e-1d4a-4c55-9a3e-2b7d0c9e8f11","source":{"method":"link","ref":"tiktok-oscar-3f9a"}},{"type":"payment","event_id":"in_1PqXyZ","value_cents":1999,"currency":"EUR","visitor_id":"6f1c2b9e-1d4a-4c55-9a3e-2b7d0c9e8f11","variants":["pricing_page:b"]}]}'An event
| Field | Type | Required | Description |
|---|---|---|---|
type | string | Required | One of signup, activation, trial, payment, cancel, source, exposure, custom. |
event_id | string | Required | Your unique id for the event (1 to 64 characters: letters, digits, _ . : -). The same id is only counted once: it is the idempotency key. For a payment, use the invoice or order id, the same as in track("payment", { id }). |
occurred_at | string | Optional | ISO 8601 date with a time zone (2026-10-12T14:03:00Z), within the last 72 hours. Defaults to the time of receipt. |
visitor_id | string | Optional | The value of firstmrr.visitorId() in the browser, or your app's install id (8 to 64 letters, digits or dashes). Ties the event to the same day's visitor. |
value_cents | integer | Optional | Payment only: the amount in cents (1999 for 19.99). |
currency | string | Optional | 3-letter ISO 4217 code, required with value_cents. |
source | object | Optional | The origin returned by firstmrr.source() at signup, as is: method among link, utm, in_app, referrer, with platform or ref (at is accepted and ignored). |
variants | string[] | Optional | The visitor's A/B assignments, "test:variant", 4 at most. Required for exposure. |
name | string | Optional | custom events only: its name, 1 to 40 lower-case letters, digits or _. |
answer | string | Optional | source events only: the answer to "Where did you hear about us?", among tiktok, instagram, youtube, x, threads, facebook, linkedin, snapchat, pinterest, google, app_store, friend, press, other. |
account | string | Optional | source events only: the account the person names ("@account"), 60 characters at most. An e-mail address is refused. |
Response
202 Accepted once the request is read: accepted counts the stored events, duplicates those already received with the same event_id, and rejected gives, for each refusal, its position in your request, a code and the field at fault.
{
"accepted": 1,
"duplicates": 1,
"rejected": [
{
"index": 2,
"error": "unknown_field",
"field": "email",
"message": "This field is not part of the schema (personal data such as e-mails or names is never accepted)."
}
]
}Errors
A request error refuses the whole request, with { "error": { "code", "message" } }:
| Code | HTTP | Description |
|---|---|---|
missing_key | 401 | Send your secret key in the Authorization header: Bearer sk_live_… |
invalid_key | 401 | This secret key does not exist or was revoked. |
invalid_json | 400 | The body is not valid JSON. |
body_too_large | 400 | The body is larger than 64 KB. |
invalid_body | 400 | The body must be an object with an events array, and nothing else. |
empty_batch | 400 | The events array is empty. |
too_many_events | 400 | Send 100 events per request at most. |
rate_limited | 429 | Too many requests: 600 per 15 minutes per account. Retry after the Retry-After delay. |
unavailable | 503 | The events API is momentarily unavailable. Retry later. |
An event error only refuses that event, in rejected:
| Code | Scope | Description |
|---|---|---|
invalid_event | event | The event must be an object. |
unknown_field | event | This field is not part of the schema (personal data such as e-mails or names is never accepted). |
invalid_type | event | type must be one of signup, activation, trial, payment, cancel, source, exposure, custom. |
invalid_event_id | event | event_id is required: 1 to 64 letters, digits, _ . : or -. |
invalid_occurred_at | event | occurred_at must be an ISO 8601 date with a time zone, within the last 72 hours. |
invalid_visitor_id | event | visitor_id must be 8 to 64 letters, digits or dashes (firstmrr.visitorId() or the app's install id). |
invalid_value | event | value_cents must be a whole number of cents, from 0 to 9 999 999, on a payment only. |
invalid_currency | event | currency must be a 3-letter ISO 4217 code, given with value_cents. |
invalid_source | event | source must be an object with method among link, utm, in_app, referrer and its platform or ref. |
invalid_variants | event | variants must be a list of 1 to 4 "test:variant" strings, one per test. |
invalid_name | event | name is required on a custom event, only there: 1 to 40 lower-case letters, digits or _. |
invalid_answer | event | answer is required on a source event, only there: one of tiktok, instagram, youtube, x, threads, facebook, linkedin, snapchat, pinterest, google, app_store, friend, press, other. |
invalid_account | event | account (a source event only) must be a social handle or a name of 60 characters at most, never an e-mail. |
daily_cap | event | The site reached its daily event cap: retry after 00:00 UTC. |
Idempotency and retries
An event_id already received for the same event type is never counted twice, whether it came from the API or the script (track("payment", { id })). After a network error, a 503 or a 429, resend the same batch safely: events already stored come back as duplicates.
Rate limits
- 600 requests per 15-minute window across all your keys; beyond that,
429with aRetry-Afterheader (in seconds). - 100 events per request: batch rather than sending one call per event.
- 3,000 events per day per site or app, script included; beyond that, events come back as
daily_capuntil midnight UTC.
Tying a server event to its visitor
Without visitor_id, the event is counted on its own. With the value of firstmrr.visitorId() (site) or the install id (app), it joins the same day's visitor: their visits, origin and A/B variants. Also pass source (the origin saved at signup) and variants when you have them: they are what ties a server payment to its channel and its variant.