Orishare
Concepts

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.

Orishare turns what happens in your programs (a streak about to break, a league promotion, a friend's nudge) into messages to the player: email, web push or mobile push, sent by Orishare or handed to your own messaging tool. You decide which events become messages and with which copy; Orishare applies consent, unsubscribes, quiet hours and frequency caps and records every outcome.

Reads need notifications:read and changes notifications:write. In the dashboard, notifications are under Engagement.

From event to message

  1. A mechanic records an event in the same transaction as the fact (for example league.promoted, streak.at_risk, friend.nudged). Every event also goes to your webhook endpoints, as before.
  2. Each active message type whose event_types contain that event makes one message per route (channel). The same event never makes a second message on the same channel, across retries and replays.
  3. The policy runs, in order: recipient (deleted customers get nothing; anonymous customers get no social messages), expiry, reachability (an email address, an active device), consent, frequency caps, quiet hours. The first refusal wins and is recorded as suppressed with its reason; quiet hours hold the message until the window ends.
  4. The message is rendered in the player's language and sent; the outcome is stored.

An environment with no message type behaves exactly as before: webhooks go out, nothing else.

Message types

GET /v1/notification_catalog lists the built-in triggers with their event types, variables, default copy and whether one is configured: streak_at_risk, streak_lost, streak_milestone, streak_freeze_used, level_up, badge_earned, quest_completed, quest_ending, reward_ready, league_period_started, league_promoted, league_demoted, league_ending, friend_request, friend_accepted, friend_nudge, friend_high_five, friend_quest_completed, friend_streak_at_risk and team_quest_completed.

Create a message type with POST /v1/notification_message_types:

{
  "key": "league_promoted",
  "routes": [
    { "channel": "mobile_push", "path": "builtin" },
    { "channel": "email", "path": "builtin" }
  ],
  "max_per_day": 2,
  "respect_quiet_hours": true
}

A catalogue key fills name, event_types and class from the catalogue. Any other key is a custom type and needs event_types (up to 20). class is marketing (the default; consent, opt-outs and caps apply) or transactional (for messages the player must get, such as a reward they bought; marketing opt-outs do not apply). routes has up to 8 entries, each built-in channel at most once: { "channel": "email" | "web_push" | "mobile_push", "path": "builtin" } or a connector route (below). PATCH /v1/notification_message_types/{id} changes everything but the key; POST /v1/notification_message_types/{id}/pause and /resume take { "expected_revision" }. A message type is paused, never deleted.

Templates and languages

POST /v1/notification_message_types/{id}/templates sets the copy for one channel and locale:

{
  "channel": "mobile_push",
  "locale": "fr",
  "title": "Promotion en ligue {{tier}}",
  "body": "Tu as fini {{rank}}e et tu montes en ligue {{tier}}."
}

Email takes subject, body and optionally html; push takes title, body and optionally url. Placeholders are {{name}} only, with no logic: the event's variables (see the catalogue), customer.display_name, actor.display_name (the other player of a social event, shown as the recipient is allowed to see them), unsubscribe_url, preferences_url and message_type. When the player (or the other player) has no display name, customer.display_name (or actor.display_name) renders A player. An unknown placeholder renders empty, and values are escaped in HTML. The template is chosen by the player's locale (their contact locale, else their device's, else the environment default): exact match, then the language, then default_locale, then en, then the catalogue's default copy. A custom type with no template fails with no_template.

Which placeholders an event offers. A catalogue type lists its variables in GET /v1/notification_catalog. A custom type's variables is always [] in that read, because its variables come from the event that triggers it:

  • A domain event (the economy's chest.opened, item.purchased, boost.started, ... and other orishare.domain_event/v1 events): every top-level scalar field of its data (string, number, boolean, null). Lists and objects are left out. For chest.opened that is {{item}}, {{loot_table}}, {{chests}} and {{award_count}}, but not awards. The data of each economy event is in Game economy, events.
  • A program fact without a catalogue entry (orishare.program_fact/v1): {{program_key}} and {{program_name}}.
  • The program facts the catalogue knows keep their own variables even in a custom type: streak.at_risk (streak_length, period_ends_at), streak.broken (previous_length), streak.milestone_reached (milestone, streak_length), streak.freeze_consumed (streak_length, freezes_left), tier.changed (level, previous_level, direction), badge.granted (badge_name, rank: the rank that grant awarded, so a contribution that crosses several ranks sends one message per rank), quest.completed (quest_name, claim_by; claim_by is empty for a quest program, whose quest.completed means nothing is left to claim).

{{level}} and {{previous_level}} are the level's name (its display.title, else its key; Level 3 in the game starter), not a number: write You reached {{level}}, never Level {{level}} (it renders Level Level 3). GET /v1/notification_message_types/{id}/templates lists them and DELETE /v1/notification_message_types/{id}/templates/{channel}/{locale} removes one.

PATCH /v1/notification_settings (with If-Match) holds the environment-wide rules:

FieldMeaning
email_consent_modeopt_in (marketing email needs the player's explicit consent) or opt_out.
quiet_hours{ start_minute, end_minute } of the local day (it may wrap midnight), in the player's time zone, else the environment's.
max_per_day, max_per_weekMarketing messages per player across all types. Each message type has its own as well.
default_localeThe fallback language.
from_name, reply_toEmail sender display name and reply address.

Set expires_at when timing matters: a held message whose expiry passes is suppressed with quiet_hours or expired rather than sent late, and nothing is held more than 24 hours.

Recipients

The player's address and devices belong to the notification engine; other features never see them.

  • Contact: PATCH /v1/customer/notification_contact (from the session: email, locale, email_consent, push_consent) or POST /v1/customers/{id}/notification_contact from your backend. A changed address is reachable again.
  • Devices: POST /v1/customer/notification_devices with { "kind": "web_push" | "fcm" | "apns", "token", "platform"?, "locale"? }; DELETE /v1/customer/notification_devices/{id} removes one. Tokens are never returned; a token the provider rejects is marked invalid.
  • Preferences: GET /v1/customer/notification_preferences and PATCH /v1/customer/notification_preferences with unsubscribed_all, email_consent, push_consent and types: [{ "type_key", "channel", "opted_out" }] (type_key * for every type, channel all for every channel). The hosted widget's notification_preferences surface is this page, ready made.

Every marketing email carries a signed one-click unsubscribe link (with the List-Unsubscribe and List-Unsubscribe-Post headers) and a preferences link: POST /v1/notifications/unsubscribe?token=... unsubscribes in one click (safe to repeat), while GET /v1/notifications/unsubscribe?token=... and GET /v1/notifications/preferences?token=... only read what a link refers to, for your own landing page. They need no credential; a missing, empty or forged token is 404, and a preferences link can never unsubscribe. An unsubscribe is honoured before the next send, even for a message already scheduled.

Three ways out

  • Webhook only (the default): no message type, your endpoints receive the event and you send whatever you like.
  • Built-in sending: Orishare sends the email or push itself. Test environments always use a fake provider: the message is recorded as sent with provider fake and nothing leaves Orishare, so you can test copy, consent and caps safely. In a live environment a built-in route needs Orishare's providers to be connected for your deployment; until they are, built-in messages of a claimed project fail with provider_not_configured and a connector is the way to send. In a project nobody has claimed yet (agent signup), built-in messages in a live environment are not failed but suppressed with reason project_unclaimed; claiming allows sends from then on and never replays a message suppressed before.
  • Connectors: Orishare hands the message to your own tool, with your own credentials.

Connectors (Klaviyo, Braze, OneSignal, FCM)

POST /v1/notification_connectors stores the credentials sealed; reads return only the last four characters (credential_hint) and public settings:

kindcredentialsWhat Orishare sends
klaviyo{ "api_key" }An event orishare.<type key> on the profile with your external_id; build the flow in Klaviyo.
braze{ "api_key", "rest_endpoint" } (your Braze REST instance)A custom event on the user with your external_id, or with a route template a campaign trigger with that campaign id.
onesignal{ "app_id", "api_key" }A push to the subscription whose external id alias is your external_id; with a route template, that OneSignal template, otherwise the rendered title and body.
fcm{ "service_account": { "project_id", "client_email", "private_key", ... } }A push through FCM HTTP v1 to the player's registered fcm devices.

Then route a message type to it:

{ "channel": "connector", "path": "connector", "connector_id": "ncn_...", "template": "optional" }

Your tool owns reachability, its own consent records and timing; Orishare still refuses deleted recipients, social messages to anonymous players, expired messages and, for marketing types, players who unsubscribed or opted out in Orishare. Connectors send for real from live environments; in a test environment the send is simulated (recorded as sent, nothing reaches the vendor). POST /v1/notification_connectors/{id}/disable and /enable (with { "expected_revision" }) switch one off and on; last_error shows the latest vendor refusal. A deployment without a sealing key cannot store connectors (501 capability_unavailable). See the recipe Push through your own OneSignal.

Delivery log

GET /v1/notification_messages (filters customer_id, type_key, status, channel, newest first) and GET /v1/notification_messages/{id} show every message with its rendered copy (never the address or token), attempts, provider and reason. GET /v1/notification_message_stats?since= counts by type, channel, status and reason.

statusMeaning
scheduledWaiting for its send time (quiet hours, a retry).
sendingBeing sent now.
sentAccepted by the provider or connector.
deliveredConfirmed delivered.
failedNot sent: no_template, no_device, invalid_target, provider_not_configured, provider_rejected, attempts_exhausted.
bouncedThe email bounced after sending.
suppressedNot sent on purpose: unsubscribed, opted_out, no_consent, no_address, address_bounced, address_complained, frequency_cap, quiet_hours, expired, anonymous_recipient, recipient_deleted, blocked (either player blocked the other), not_friends (a friend message after the friendship ended), project_unclaimed (built-in email or push in a live environment before the project is claimed; the claim never replays it).

Retryable provider errors are retried with backoff, up to 6 attempts.

Email bounces and complaints (Resend)

Built-in email goes through Resend. Resend reports bounces and complaints to POST /v1/notification_provider_events/resend, signed with Svix; a hard bounce or a complaint marks the address (address_bounced, address_complained) so it is not mailed again, and marks the message bounced. Whoever runs the API sets this up once:

  1. In the Resend dashboard, add a webhook to https://<your API origin>/v1/notification_provider_events/resend for the events email.bounced and email.complained.
  2. Copy its signing secret (whsec_...) into the API's NOTIFICATIONS_RESEND_WEBHOOK_SECRET and restart the API. Without it the route answers 501 capability_unavailable.
  3. Send a test event from Resend: the answer is 200 with received: true. A request with a bad or old signature (more than 5 minutes) changes nothing.

Changing notifications through change sets

In a live environment without auto autonomy an agent proposes notification configuration as change-set commands (POST /v1/change_sets): notification_message_type.create (the create body), notification_message_type.update, .pause and .resume (key, expected_version and, for update, the PATCH fields), notification_template.put (key, channel, locale and the template) and notification_template.delete (key, channel, locale; irreversible), and notification_settings.update (expected_version and the settings fields). Connectors hold credentials and are created directly by a person or a secret key, not through change sets.

The template fields sit at the top level of the command, beside type, exactly like the direct POST /v1/notification_message_types/{id}/templates body; a nested "template": { ... } object is refused:

{
  "commands": [
    {
      "type": "notification_template.put",
      "key": "level_up",
      "channel": "mobile_push",
      "locale": "fr",
      "title": "Niveau {{level}}",
      "body": "Bravo, tu passes au niveau {{level}}."
    }
  ]
}

These commands pay nothing, so in a live environment on limits they apply on request (a notification_template.delete is a deletion and is refused like any other).

On this page