Orishare
Concepts

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 }] }
}
FieldMeaning
eventThe event admitted per player. Default match.completed.
metrics1 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.
durationBounds of ended_at - started_at in seconds (1 to 86,400).
playersBounds of the number of players in one report (1 to 100).
rate_limitsPer 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.actionWhat a crossed hold_metrics threshold does: hold (wait for review) or reject.
outliers.hold_metricsThresholds (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

CodeVerdictWhen
metric_below_minimumrejectedA metric is below its minimum.
metric_above_maximumrejectedA metric is above its maximum.
metric_per_minute_exceededrejectedThe metric divided by the match minutes is above per_minute_maximum.
duration_out_of_boundsrejected (everyone)The match was shorter or longer than duration allows.
player_count_out_of_boundsrejected (everyone)The report has fewer or more players than players allows.
rate_limitedrejectedThe player already has max_matches admitted or held matches in a window.
outlierheld or rejectedA hold_metrics threshold was crossed (outliers.action).
event_refusedrejectedEvent admission refused the event for good (for example the event's trust floor).
duplicate_player_after_mergerejectedCustomers 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, source secret_key_event (the reporting key), so rules and programs with a merchant_asserted floor accept it;
  • id (the event's client_event_id) is <match_id>:<customer id>, so it is deduplicated on every retry;
  • occurred_at is the match's ended_at;
  • properties are the player's metrics plus match_id and match_profile (the profile key), and won (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 a match.completed event.

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.

On this page