Match results
Report finished matches from an authoritative game server: plausibility bounds, per-player rate windows, held results and review, and the trusted match.completed event rules turn into XP, scores and items.
A game whose server decides who won (an io game, a ranked arena, a racing server) reports each finished match to Orishare with its secret key. Orishare checks every player's result against a match profile and then, per player:
- admits it: Orishare records one trusted event for that player (by default
match.completed), so your rules, leaderboards, quests and item grants react to it like any other event; - holds it for review when a metric crosses an outlier threshold; or
- rejects it when a metric, the duration, the player count or a rate window is out of bounds.
Never report matches from the game client: a browser or app could forge them. Match results take
a secret key only (scope events:write); a publishable key or a customer session is refused.
The match profile
One profile per game mode states what a plausible match looks like. Profiles are configuration:
you write them through change sets (operation commands match_profile.create,
match_profile.update and match_profile.archive, which the dashboard, the assistant and MCP
use) and read them with GET /v1/match_profiles and GET /v1/match_profiles/{id} (id or key).
In a test environment (and in a live environment on auto) you apply the change set yourself with
POST /v1/change_set_applications. Under the live environment's limits policy,
match_profile.create and match_profile.update apply on request (they pay nothing), and
match_profile.archive is destructive and waits for a signed-in owner or admin to confirm it in
the dashboard (see Autonomy).
{
"type": "match_profile.create",
"key": "arena_ranked",
"event": "match.completed",
"metrics": [
{ "key": "score", "minimum": 0, "maximum": 50000, "per_minute_maximum": 4000 },
{ "key": "kills", "minimum": 0, "maximum": 60 },
{ "key": "placement", "minimum": 1, "maximum": 40 }
],
"duration": { "minimum_seconds": 60, "maximum_seconds": 1200 },
"players": { "minimum": 2, "maximum": 40 },
"rate_limits": [
{ "window_seconds": 600, "max_matches": 5 },
{ "window_seconds": 86400, "max_matches": 120 }
],
"outliers": { "action": "hold", "hold_metrics": [{ "key": "kills", "above": 40 }] }
}| Field | Meaning |
|---|---|
event | The event admitted per player. Default match.completed. |
metrics | 1 to 16 metrics every report must carry for every player, each with minimum and maximum. per_minute_maximum caps how fast it may grow: the value divided by the match minutes. |
duration | Bounds of ended_at - started_at in seconds (1 to 86,400). |
players | Bounds of the number of players in one report (1 to 100). |
rate_limits | Per player, at most max_matches admitted or held matches ending within any window_seconds (60 to 86,400) long window. Up to 8 windows. |
outliers.action | What a crossed hold_metrics threshold does: hold (wait for review) or reject. |
outliers.hold_metrics | Thresholds (above) on metrics of the profile that are plausible but worth a look. |
match_id and match_profile are reserved: a metric cannot use those keys. An update replaces
each section it names and keeps the others; matches already reported keep the profile revision
they were checked against. match_profile.update takes key (the key or the id),
expected_version (the profile's revision from GET /v1/match_profiles/{id}) and any of
event, metrics, duration, players, rate_limits and outliers, each in the same shape as
in the create. For example, to accept short matches (the examples here use a 60 second minimum):
{
"commands": [
{
"type": "match_profile.update",
"key": "arena_ranked",
"expected_version": 1,
"duration": { "minimum_seconds": 10, "maximum_seconds": 1200 }
}
]
}
``` Archiving a profile refuses new reports against it
(`match_profile_archived`) and keeps stored results and pending reviews.
## Reporting a match
`POST /v1/match_results` with the secret key of the environment:
```bash
curl https://api.orishare.com/v1/match_results \
-H "Authorization: Bearer $ORISHARE_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"profile": "arena_ranked",
"match_id": "arena-2026-10-04-0001",
"started_at": "2026-10-04T11:52:00Z",
"ended_at": "2026-10-04T12:00:00Z",
"players": [
{ "customer": { "external_id": "player_8812" }, "metrics": { "score": 12400, "kills": 9, "placement": 1 } },
{ "customer": { "external_id": "player_4410" }, "metrics": { "score": 31000, "kills": 52, "placement": 2 } },
{ "customer": { "external_id": "player_1207" }, "metrics": { "score": 90000, "kills": 3, "placement": 3 } }
]
}'Name each player as events do: customer: { "external_id": "..." } with your own player id (an
unknown one creates the customer), or customer: { "id": "cust_..." } with the Orishare id (an
unknown one is resource_not_found). Every player must carry exactly the profile's metrics.
A player may also carry won, true or false: whether that player won. It is not a metric (no
profile may name a metric won, match_id or match_profile), it is part of the report (the same
match_id with another won is another body, idempotency_key_reused), and each player of the
answer shows it (null when the report gave none).
ended_at may be at most 5 minutes ahead of the server clock and at most 7 days old.
The answer is 201 with every player's verdict:
{
"id": "mr_0123456789abcdef0123456789abcdef",
"match_id": "arena-2026-10-04-0001",
"profile": "arena_ranked",
"match_profile_id": "mpf_0123456789abcdef0123456789abcdef",
"profile_revision": 1,
"event": "match.completed",
"started_at": "2026-10-04T11:52:00.000Z",
"ended_at": "2026-10-04T12:00:00.000Z",
"duration_seconds": 480,
"status": "held",
"players": [
{
"id": "mrp_0123456789abcdef0123456789abcde1",
"customer_id": "cust_0123456789abcdef0123456789abcde1",
"metrics": { "score": 12400, "kills": 9, "placement": 1 },
"status": "admitted",
"reasons": [],
"event_id": "evt_0123456789abcdef0123456789abcde1",
"client_event_id": "arena-2026-10-04-0001:cust_0123456789abcdef0123456789abcde1",
"review": null
},
{
"id": "mrp_0123456789abcdef0123456789abcde2",
"customer_id": "cust_0123456789abcdef0123456789abcde2",
"metrics": { "score": 31000, "kills": 52, "placement": 2 },
"status": "held",
"reasons": [
{
"code": "outlier",
"message": "kills 52 is above the review threshold 40",
"metric": "kills",
"value": 52,
"limit": 40
}
],
"event_id": null,
"client_event_id": "arena-2026-10-04-0001:cust_0123456789abcdef0123456789abcde2",
"review": null
},
{
"id": "mrp_0123456789abcdef0123456789abcde3",
"customer_id": "cust_0123456789abcdef0123456789abcde3",
"metrics": { "score": 90000, "kills": 3, "placement": 3 },
"status": "rejected",
"reasons": [
{
"code": "metric_above_maximum",
"message": "score 90000 is above the maximum 50000",
"metric": "score",
"value": 90000,
"limit": 50000
},
{
"code": "metric_per_minute_exceeded",
"message": "score grew 11250 per minute, above 4000",
"metric": "score",
"value": 11250,
"limit": 4000
}
],
"event_id": null,
"client_event_id": "arena-2026-10-04-0001:cust_0123456789abcdef0123456789abcde3",
"review": null
}
],
"replayed": false,
"admission_errors": []
}(Player created_at and updated_at and the match's revision, created_at and updated_at
are left out above.) The match status is held while any player waits for review, otherwise
admitted (every player), rejected (every player) or partially_admitted.
Reasons
| Code | Verdict | When |
|---|---|---|
metric_below_minimum | rejected | A metric is below its minimum. |
metric_above_maximum | rejected | A metric is above its maximum. |
metric_per_minute_exceeded | rejected | The metric divided by the match minutes is above per_minute_maximum. |
duration_out_of_bounds | rejected (everyone) | The match was shorter or longer than duration allows. |
player_count_out_of_bounds | rejected (everyone) | The report has fewer or more players than players allows. |
rate_limited | rejected | The player already has max_matches admitted or held matches in a window. |
outlier | held or rejected | A hold_metrics threshold was crossed (outliers.action). |
event_refused | rejected | Event admission refused the event for good (for example the event's trust floor). |
duplicate_player_after_merge | rejected | Customers merged after the report: another player of the match in the same customer group was already admitted. |
Bounds are checked first; rate windows only for results inside the bounds; outliers last. Concurrent reports for the same player are counted one at a time, so they can never both slip under a rate window.
Retries
The match_id is the idempotency key, so the route takes no Idempotency-Key header. Sending the
same match again with the same body answers 200 with the stored result and replayed: true,
and changes nothing. The same match_id with a different body is refused with
idempotency_key_reused. A retry also finishes an admission an interrupted request left pending:
when admission_errors lists a player (the usage allowance or quota was exhausted, or the
reporting key was revoked), that player stays admitted with event_id: null until you report
the same match again.
The admitted event
Each admitted player becomes one event, exactly as if your server had sent it with
POST /v1/events:
- trust
merchant_asserted, sourcesecret_key_event(the reporting key), so rules and programs with amerchant_assertedfloor accept it; id(the event'sclient_event_id) is<match_id>:<customer id>, so it is deduplicated on every retry;occurred_atis the match'sended_at;propertiesare the player's metrics plusmatch_idandmatch_profile(the profile key), andwon(a boolean) when the report gave it. A rule pays a win with the condition{ "path": "event.properties.won", "op": "eq", "value": true }, the same rule that pays a win your backend or the player's app sends as amatch.completedevent.
A rule that awards XP for every admitted match and a bonus for a win:
{
"schema": "orishare.rule/v2",
"trigger": { "kind": "customer_event", "event": "match.completed" },
"trust": { "minimum": "merchant_asserted" },
"actions": [{ "type": "points.award", "currency": "xp", "amount": 50 }]
}{
"schema": "orishare.rule/v2",
"trigger": { "kind": "customer_event", "event": "match.completed" },
"trust": { "minimum": "merchant_asserted" },
"conditions": { "all": [{ "path": "event.properties.placement", "op": "eq", "value": 1 }] },
"actions": [{ "type": "item.grant", "item": "victory_crate", "quantity": 1 }]
}A weekly XP leaderboard with score_source: { "type": "currency_earned", "currency": "xp" } then
ranks players by the XP their matches earned, and quests can count match.completed events. See
rules and trust policy.
Review
GET /v1/match_results?status=held is the review queue: matches with at least one held player,
oldest first. GET /v1/match_results/{id} reads one match by its mr_ id or by your match_id.
An operator (scope programs:write) decides a held player with
POST /v1/match_result_players/{id}/reviews and an Idempotency-Key:
{ "decision": "admit", "reason": "Replay checked, the streak is real" }admit records the player's event now, with occurred_at still the match's ended_at: a rule
or program with a short evidence window may treat it as late. reject rejects it for good. The
review is stored with the player (review.decision, reason, actor, reviewed_at). The same
decision again answers with replayed: true and admits nothing new; the other decision, or a
review of a player that was never held, is invalid_state. A held player of a match whose
reporting key has since been revoked cannot be admitted.
Limits
- A report has 1 to 100 players and each player appears once.
- Rate windows count matches by the player's ownership group (merged customers share them).
- The reasons record the bound that was hit, not the whole profile; read the profile revision with
GET /v1/match_profiles/{id}to see the rest.
Notifications
From event to message: message types, templates and locales, consent, quiet hours and caps, built-in sending, webhooks and connectors (Klaviyo, Braze, OneSignal, FCM), the delivery log and unsubscribe links.
Game economy
Items, a shop, inventory, energy and lives, timed boosts, chests with published odds and battle passes: the configuration commands, the player and backend routes, the events, the limits, and one complete recipe.