REST API & webhooks
One API behind everything: the inbox, Telegram, Discord, the MCP server and the CLI are all clients of it.
Basics
- Base URL:
https://app.inngame.app/v1(the previoushttps://inngame.meffysto.workers.dev/v1keeps working) - Auth:
Authorization: Bearer ig_sk_…(an API key). The key determines the organization. - Format: JSON in and out. Timestamps are Unix milliseconds. IDs are prefixed strings (
cnv_…,msg_…,eu_…for players,bug_…). - Errors: a non-2xx status with
{"error": "code", "message": "…"}. Common codes:invalid_api_key(401),requires_reply/requires_admin(403),human_approval_required(403),conversation_not_found(404). - Scopes:
read<reply<all. In the tables below,allmeans admin rights. Every write is recorded in the audit log. - Rate limits: 300 requests a minute per API key; for players, 20 messages or uploads and 60 events a minute. Over the limit you get
429 rate_limited: wait a minute and retry. - Pagination: lists return up to 50 items (100 max with
limit) and a cursor: passnextBeforeasbeforefor conversations, or the oldestseqasbeforefor messages.
curl "https://app.inngame.app/v1/conversations?status=open&unread=1" \
-H "Authorization: Bearer $INNGAME_API_KEY"
Conversations and messages
| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /conversations | read | Filters: status (open, closed, snoozed, all), app, unread=1, assignee (me, none, user id), tag, lang (player language: pt also matches pt-BR; und = unknown), q (name, email, Steam id, message text, translations), before, limit. Most urgent first. |
| GET | /conversations/:id | read | Conversation, last 50 messages (with translations, context, drafts, notes) and the player profile (customer). |
| GET | /conversations/:id/messages | read | Older messages: ?before=<seq>. |
| POST | /conversations/:id/messages | reply | {body, note?, attachments?, card?, lang?}. Translated for the player. note: true for an internal note. Reopens a closed conversation. |
| POST | /conversations/:id/drafts/:msgId/approve | reply | Send an AI draft, optionally with an edited body. |
| DELETE | /conversations/:id/drafts/:msgId | reply | Discard an AI draft. |
| POST | /conversations/:id/messages/:msgId/retract | reply | Unsend a message sent to the player (team, AI or automation). It leaves the player's chat at once and stays struck through for the team. An item already claimed can't be taken back (409 card_already_claimed). An API key can unsend messages from the AI, automations and agents, not from a person. |
| POST | /conversations/:id/ai-suggest | reply | Have the built-in AI answer or draft now. |
| PATCH | /conversations/:id | reply | {status, snoozeUntil, assigneeId, aiMode, priority}. aiMode: inherit, auto, draft, off. priority: 0–2. |
| POST | /conversations/:id/read | read | Mark as read by the team. |
| POST | /conversations/:id/tags | reply | {name} |
| DELETE | /conversations/:id/tags/:name | reply | |
| GET | /tags | read | Tags with usage counts. |
| GET | /saved-replies | read | Saved replies; POST, PATCH, DELETE need reply. |
| POST | /uploads | reply | Multipart file; returns an attachment to put in attachments. |
| GET | /stats | read | days (≤ 365), app: volumes, first response time, AI share, satisfaction, per-day series, conversations by player language (byLanguage). |
Cards
Add a card to a message to show a button in the chat:
{
"body": "Sorry about that! Here's a little something.",
"card": {
"kind": "item", // review | offer | link | screen | item | refund | restore
"label": "Claim",
"item": { "id": "gems_500", "name": "500 gems", "quantity": 1 }
}
}
Other fields: url (link, review fallback), screen and value (screen), offerId (offer). An item card can be claimed once; you get an item.claimed webhook to grant it on your server.
Leave out label on review and refund cards to show the SDK's own text in the player's language (“Leave a review”, “Request a refund”). Cards Inngame sends itself carry the English default plus "defaultLabel": true; SDK 0.2.2 and later replace it with the translated text.
Players
| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /users | read | app, q, before. Most recently seen first. |
| GET | /users/:id | read | Profile, devices, metadata (including subscription status from RevenueCat) and conversations. |
| PATCH | /users/:id | reply | {teamName, blocked}. teamName is a name only your team sees. |
| POST | /users/:id/messages | reply | Write first to a player who has no conversation yet. |
| GET | /users/:id/export | read | Everything held on the player as a JSON file, for data access requests: profile, devices, conversations with messages, translations, context and tags, bug reports. Photos as links valid 7 days. No tokens. Logged as user.export. |
| DELETE | /users/:id | human admin | Delete the player, their conversations and files. Agent keys get 403 human_approval_required. |
Apps, organization and keys
| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /org | read | Organization, plan, AI provider, this month's usage, and who you are (key name and scope). |
| PATCH | /org | all | {name, aiProvider, aiKey, aiModel}: see BYOK. |
| DELETE | /org | owner, signed in | {confirm} with the exact organization name. Deletes everything in the organization (apps, players, conversations, files, keys, integrations) and cancels its subscription. API keys can't. |
| GET | /apps | read | Apps with public keys and settings. |
| GET | /apps/:id | read | One app. The identity secret is included only for admins. |
| POST | /apps | all | Create an app. |
| PATCH | /apps/:id | all | Rename or change settings (deep-merged): AI mode and pause, translation, moderation, appearance (texts; light and dark colors as #RRGGBB: accent, background, text, header, incoming, outgoing), review ids, verified-only, retention.closedDays (0 = keep forever, else 7 to 3650), csat ({enabled, askReview}: ask players to rate closed conversations, then offer the store review to those who rate 4 or 5)… Moderation, identity and retention need a human admin. |
| DELETE | /apps/:id | all | |
| POST | /apps/:id/rotate-identity-secret | all | {unverify?} |
| POST | /apps/:id/logo | all | Upload the chat logo: multipart field file, PNG or JPEG, up to 512 KB. Returns {logoUrl}, a public URL that changes on every upload (cache it forever). DELETE removes it. Players get it as appearance.logoUrl in the SDK config. |
| GET | /members | read | Team members. Invitations and roles need all. |
| GET | /api-keys | human admin | POST {name, scopes} creates a key (shown once); DELETE revokes. Agent keys can't manage keys. |
| GET | /audit | all | Audit log: app, actor, target, before. Each entry says whether it is undoable or already undone. |
| POST | /audit/:id/undo | admin, signed in | Undo an entry: unsend the messages it sent (replies, AI answers, cards, automations), remove the tag, put the conversation back as it was, or unblock the player. Once per entry. API keys can't undo. |
| GET | /billing | read | Plan, trial end, usage and quota. Checkout is for signed-in human admins only. |
Bugs
The same bugs as the Bugs page of the inbox. Every bug has a url to that page, which your teammates can open from Telegram, Discord, the weekly digest or an agent's summary.
| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /bugs | read | app, status (open, fixed, released, wontfix; comma-separated for several), q (search), sort (players, newest, recent), limit (max 200). Each bug comes with reports (players), reports24h, notified, firstSeen, lastSeen, versions, platforms and url; plus counts per status. |
| GET | /bugs/counts | read | {open, fixed, closed, all}, optional app. |
| GET | /bugs/:id | read | Bug, players over 30 days (timeline), breakdown by version, platform, device and OS, players' current versions, log excerpts with the last error (samples), linked conversations (reports), whether GitHub is connected, and a preview of the issue body. |
| POST | /bugs | reply | {conversationId, bugId} to link a conversation, or {conversationId, title, summary} to create a bug. |
| PATCH | /bugs/:id | reply | {title, summary, status, fixedInVersion}. Setting fixedInVersion notifies reporters who already run it, and the others when they update. Returns notified. |
| POST | /bugs/:id/github-issue | reply | Opens the issue in the connected repository. Returns url. |
| POST | /bugs/:id/merge | reply | {into}: moves the reports to another bug of the same app and deletes this one. A player in both is counted once. |
| POST | /bugs/:id/request-fix | all | Comments on the issue to ask the repository's coding agent (default @claude) for a PR. Never merges. |
Refunds
| Method | Path | Scope | Notes |
|---|---|---|---|
| POST | /conversations/:id/refund-proposal | reply | {provider, reference, amountCents?, reason}, provider: stripe, googleplay, apple. Leaves a note for an admin; returns 202 awaiting_human_approval. |
| POST | /conversations/:id/refund | human admin | API keys get 403 human_approval_required. Stripe {paymentId, amountCents?}, Google Play {orderId, revoke}, Apple {transactionId} (sends Apple's refund sheet to the player). |
FAQ and automations
| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /apps/:id/faq | read | Articles. |
| POST | /apps/:id/faq | reply | {title, body, published}. PATCH and DELETE on /apps/:id/faq/:articleId. |
| POST | /apps/:id/faq/import | all | {url, replace}: an https page or sitemap (up to 30 pages). |
| POST | /apps/:id/faq/generate | all | Drafts up to 15 unpublished articles from past conversations. |
| GET | /apps/:id/automations | read | Automations with 30-day run counts, subscription events and suggested game events. |
| POST | /apps/:id/automations | all | Create. Returns the Track snippet for Unity and Swift. PUT and DELETE on /apps/:id/automations/:automationId. |
An automation has a triggerEvent (a game event or a subscription event such as trial_started, subscription_cancelled, billing_issue), a frequency, an audience (rules on player metadata), a delay (now, minutes or local_time) and actions: message, card (offer, link, screen, item), review, open_chat, wait.
SDK endpoints
The SDKs call /v1/sdk/* with the public app key in X-App-Key and a device token as bearer. Use them directly only if you're writing your own client (a web game, another engine).
| Method | Path | Scope | Notes |
|---|---|---|---|
| POST | /sdk/session | app key | {device, identity?}. Returns a device token (keep it), the player, conversation id, unread count and remote config. The config has availability: {online, replyTime, nextOpenAt}: replyTime is "minutes", "hours", "day" or null (too little history, or hidden); nextOpenAt (Unix ms) is set when the team is outside its office hours. |
| POST | /sdk/identify | device | {userId, name, email, token, steamTicket, metadata} |
| GET | /sdk/messages | device | ?before=<seq> |
| POST | /sdk/messages | device | {body, clientId, attachments, context}. clientId makes retries safe; context ≤ 64 KB. |
| POST | /sdk/uploads | device | Multipart file. Images are moderated; rejected ones return 422 photo_rejected and are never stored. |
| POST | /sdk/read | device | Mark team messages as read. |
| GET | /sdk/unread | device | Unread count (for polling). |
| POST | /sdk/push | device | {token, kind}, kind: apns, apns_sandbox, fcm. null unregisters. |
| POST | /sdk/events | device | {name, props, opensChat}. May return openChat: true. |
| POST | /sdk/cards/:messageId/action | device | {action, nonce?, rating?}: tapped, failed, claimed, dismissed, rated. For rated (satisfaction cards only), send rating from 1 to 5; only the first rating counts and the answer returns it. For claimed, send a random nonce and keep it for retries: the answer has granted: true only for the claim that won (or its retry with the same nonce). Grant nothing otherwise. 404 if the team unsent the card. |
| GET | /sdk/faq | app key | ?q= searches published articles. |
| POST | /sdk/logout | device | Device continues as a new anonymous player. |
| POST | /sdk/delete-data | device | Erase the player. |
| GET | /sdk/ws | device | WebSocket for real-time messages (app_key and token as query parameters). |
Webhooks
Webhooks send events to your server: grant claimed items, log refunds, page someone on a bug spike. Manage them in the inbox (Settings → Webhooks) or with the API, signed in as an admin. Agent keys can't create or read webhooks, even with scope all: a webhook can send every message to any URL, so a person sets it up.
| Method | Path | Scope | Notes |
|---|---|---|---|
| GET | /webhooks | admin | Your webhooks, last delivery status, and the list of events. |
| POST | /webhooks | admin | {url, appId?, events?}. https only. events defaults to ["*"]. Returns the secret (whsec_…) once. |
| POST | /webhooks/:id/test | admin | Sends a ping event and returns the result. |
| DELETE | /webhooks/:id | admin |
Each delivery is a POST with a JSON body, a user-agent of Inngame-Webhooks/1 and an inngame-signature header. Respond with a 2xx within 8 seconds. Failed deliveries (network errors, timeouts, 408, 429 and 5xx) are retried after about 1 and 5 minutes, then 30 minutes, 2, 6 and 12 hours: seven attempts over about a day. Another 4xx is taken as a refusal and not retried. Retries keep the same event id, so make your handler idempotent. The last result is on the webhook (last_status, last_error, pending retries). POST /webhooks/:id/test pings that webhook only.
{
"id": "evt_8sd7f6g5h4j3k2l1m0n9",
"type": "item.claimed",
"created": 1793452800000,
"appId": "app_...",
"data": {
"messageId": "msg_...",
"conversationId": "cnv_...",
"player": { "id": "eu_...", "externalId": "player-1842", "steamId": null },
"card": { "kind": "item", "label": "Claim", "item": { "id": "gems_500", "name": "500 gems", "quantity": 1 }, "claimedAt": 1793452800000 },
"action": "claimed"
}
}
Event list
| Event | When | data |
|---|---|---|
message.created | A message is sent in a conversation (player, team or AI; not drafts or notes). | conversationId, playerId, message |
item.claimed | A player claims an item card (once per card). | messageId, conversationId, player, card, action |
card.action | A player taps, dismisses or fails to open any other card. | Same as above. |
refund.issued | An admin issued a refund or sent the Apple refund sheet. | conversationId, playerId, provider, id, status |
bug.spike | Reports for a bug spike within 24 hours. | bugId, title, reports24h, url |
conversation.closed | A conversation is closed (inbox, API, Telegram or Discord). | conversationId, playerId, by |
player.blocked | A player is blocked. | playerId, by |
message.retracted | The team unsent a message. | conversationId, messageId, playerId, by |
conversation.rated | A player rated a closed conversation (satisfaction on). | conversationId, messageId, rating (1 to 5), player |
digest.weekly | The Monday digest of the past week. | apps[], suggestions[] |
ping | Sent by the test endpoint. | hello |
Verifying signatures
The header looks like this:
inngame-signature: t=1793452800,v1=pZ3f…Kq8
tis the Unix time in seconds when the event was sent.v1is the HMAC-SHA256 of the stringt + "." + raw body, keyed with your webhook secret, encoded as base64url (no padding).- Compute it over the raw request body, before any JSON parsing. Compare in constant time, and reject events whose
tis more than 5 minutes old.
// Node.js (Express)
import crypto from "node:crypto";
app.post("/inngame-webhook", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("inngame-signature") ?? "";
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
const t = Number(parts.t);
const body = req.body.toString("utf8");
const expected = crypto
.createHmac("sha256", process.env.INNGAME_WEBHOOK_SECRET)
.update(`${t}.${body}`)
.digest("base64url");
const ok =
parts.v1 &&
parts.v1.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected)) &&
Math.abs(Date.now() / 1000 - t) < 300;
if (!ok) return res.status(400).send("bad signature");
const event = JSON.parse(body);
if (event.type === "item.claimed") grantItem(event.data.player, event.data.card.item);
res.sendStatus(200);
});
# Python
import base64, hashlib, hmac, time
def verify(header: str, body: bytes, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = parts.get("t", "")
mac = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).digest()
expected = base64.urlsafe_b64encode(mac).rstrip(b"=").decode()
return hmac.compare_digest(parts.get("v1", ""), expected) and abs(time.time() - int(t)) < 300