Management client and resource lifecycle
The management client and its errors, project settings, resource lifecycle, lifecycle and undo commands, and scheduled hosted jobs.
Read this when you operate a project and the resources it published. It covers the management client and its error contract, project identity and settings, resource lifecycle, canonical lifecycle commands, recovering a quest lifecycle change and the keys that help, operator undo commands, and scheduled hosted jobs. Authoring covers writing and publishing quests, rewards and presentation.
Management client
Management SDK calls send X-Domino-Client: @domino-sdk/relay/<version> (<runtime>). createManagementClient also takes fetch, which replaces the global fetch for every call (a test double, a proxy's or an edge runtime's own), and headers, added to every call; a raw call's own headers and the client's authorization, scope and content-type headers replace one of the same name, and a getToken that returns null sends no Authorization header at all. Response readers ignore added fields at every depth. Legacy methods retain closed 1.x return types.
Use client.response(path, schema, input?, method?, call?) for an extensible response. It accepts branded tolerant schemas, sends X-Domino-Response: tolerant-v1, and returns { status, operationId?, result }. managementResponses from @domino-sdk/relay/server provides supported/unsupported envelopes for every named management response. questCatalogResponseSchema from @domino-sdk/relay/schema preserves unsupported catalog entities individually. responseListSchema(entitySchema) retains entity-list positions and reports unsupportedCount.
Unknown requirements or policies keep the entire entity as { kind: "unsupported", raw }. Never act on an unsupported financial result or guess its outcome. Persist a write's actionId before sending; response writes are sent once and return that identity when the input supplies it. Older clients and legacy methods in the newest SDK still need their supported wire shapes.
createManagementClient from @domino-sdk/relay/server (or hostedManagementClient(env) in a hosted Worker) has a typed method for each management route an app or script needs. Use client.request(path, schema, input?) or client.raw(path, init?) only for routes without one, such as build event streams. From a terminal or an agent, the typed domino <noun> list|get|… commands call these same methods and print their results, such as domino points award for points.record or domino trackers ingest for trackers.ingest; each change is a saved operation whose --operation-id becomes its actionId. See resource commands; prefer them to domino api.
Definitions change only in test. In live, every call that writes one fails with a ManagementError of status 422 and code promotion-required, whatever the credential: quest publication, settings and archive state, the Quest Studio catalog and drafts, point ledgers, badges, the calendar, profile settings, pickup policy, a leaderboard's exclusion-group binding, stock item creation, renames and archive state, and every other archive or unarchive. Operations work in live under the caller's permissions: pauses, points, groups, moderation, reviews, reward corrections, settlements, stock adjustments and stopping or reverting a member import (memberImports.stop(id), memberImports.revert(id, { reason }); applying a live import stays Console-only, see member imports). Do not retry a promotion-required refusal; make the change in test, then run domino promote. See test and live.
client.storage.list(), domino storage list --json or GET /management/v1/storage reads hosted app storage in test with read permission. It returns declared and retained stores with their kind, binding name, declaration status, and creation time, never Cloudflare IDs. With publish, client.storage.tables(name), rows(name, table, { cursor }), cell(name, table, { row, column, cursor }), keys(name, { prefix, cursor }) and value(name, key) browse contents read-only (cell reads one SQL value in full where rows cut it), clear(kind, name, input) empties SQL tables or deletes KV keys in test only ({ tables } or { allTables: true, keepTables }, { prefix } or { all: true }, optional dryRun; KV deletes 10,000 keys a call, so repeat until complete), and delete(kind, name) removes a store the serving build no longer declares. ManagementClient types storage as optional, so code that implements the type keeps compiling; clients from createManagementClient always have it. Declare native KV and D1 bindings in relay.json app.storage; see hosted storage.
| Method | Route | Permission |
|---|---|---|
members.get(id), members.people(ids) | GET /members/<id>, GET /people?ids= | read |
members.lookup({ kind, subject }) | GET /members/lookup | read |
x.lookup({ username }) | GET /x/users/lookup?username= | read |
members.progress(id) | GET /members/<id>/progress | read |
Experimental: members.consent(id), members.consentHistory(id, { cursor, limit }): the member's current marketing consent per channel, and every change newest first as { items, nextCursor }, each { member, channel, state, at, wording, source }. Read-only: only the member grants or withdraws, apart from consent a member import carries over (source: "imported"). domino members consent <id> prints both | GET /members/<id>/consent, GET /members/<id>/consent/history | read |
members.identityStates.list(id) | GET /members/<id>/identity-states | read |
members.identityStates.unlink(id, input), relink(id, input), setSignIn(id, input) | POST /members/<id>/identity-states/{unlink,relink,sign-in} | review |
Experimental: members.ban(id, { reason, rollback?, actionId? }), members.previewBan(id, { rollback? }), members.unban(id, { reason, restoreCompletions?, actionId? }): bans a member in this project and environment, live or test (domino members ban MEMBER --reason TEXT [--rollback completions,referral-payouts,point-awards] [--dry-run] [--yes], domino members unban MEMBER --reason TEXT [--restore-completions]; see Ban a member). While banned, attempts, completions, invitation acceptances and the member's invitation code are refused (403 member-banned; the code 404 invitation-not-found), attempts waiting for review or a deferral are rejected, tracker records that count for them are skipped with member-banned and never decided again, leaderboards and settlements frozen while banned leave them out (a pending settlement's approval withholds their prize), referral payouts to and from them stop, engagement payouts wait, imports refuse their rows, and relay.me.get() answers eligible: false. rollback ({ completions?, referralPayouts?, pointAwards? }, default none) runs in the background a step at a time: completions revokes every completion through the revoke path, keeping occurrences (inviter bonuses and qualifications follow); referralPayouts reverses and writes off the member's referral accounts, keeping what they spent as referral debt; pointAwards reverses manual awards, imported opening balances and engagement payouts. Balances never go negative. previewBan is the dry run: per ledger { completions, pointAwards, referralPayouts, available, reversed, unreversed, referralDebt }, attempts, completions, entries, referralAccounts, and inviters with the bonus corrected. Answers the ban { id, member, reason, bannedAt, bannedBy, rollback: { completions, referralPayouts, pointAwards, status, finishedAt, outcome }, unban }; members.get answers it as ban, member lists bannedAt. Banning a banned member adds missing rollback or answers the ban unchanged; 409 member-ban-restoring while an unban still restores. unban stops a running rollback; restoreCompletions restores what the rollback revoked; reversed payouts and awards stay. 409 member-not-banned with no ban in force. Action IDs replay. In live, a domino login session is refused (console-approval-required): use a personal API key | POST /members/<id>/ban, …/ban/preview, …/unban | review; publish too for referralPayouts or pointAwards |
members.erase(id, { reason, actionId? }): erases the member on the person's request, in live or test, and can't be undone (domino members erase MEMBER --reason TEXT [--yes]; see Erase a member on request). Deletes the profile, sign-in identities, linked accounts and tokens, app identities, wallets, sessions, consent, aliases, referral code and every attempt's evidence, and forgets the content of the tracker records their accounts authored; typed reasons on kept rows read [erased]. Points, completions, rewards and referrals stay under the member ID, and member reads show erasedAt. Answers { member, erasedAt, deleted }. Refused with 409 member-erase-blocked (state) while an attempt waits for a decision or a handover is in progress; afterwards writes naming the member fail with 409 member-erased. A repeat answers the first erasure | POST /members/<id>/erase | publish |
members.accounts(id) | GET /members/<id>/accounts | read |
members.unlinkAccount(id, { provider, subject, expectedRevision, reason, actionId? }) | POST /members/<id>/accounts/unlink | review |
Deprecated: members.oauthAccounts, unlinkOAuthAccount, telegramAccount, unlinkTelegramAccount (use members.accounts and unlinkAccount), unlinkWallet, setWalletSignIn, unlinkIdentity, relinkIdentity, identityLinks | Their 1.x routes | As before |
attempts.confirm({ quest, member }) | POST /attempts | confirm |
attempts.review(id, { generation, decision, reason }), attempts.retry(id) | POST /attempts/<id>/review, …/retry | review |
attempts.acceptAnyway(attempt, { reason }), redactEvidence(attempt, { reason }); the ({ attempt, reason }) form also works | POST /attempts/<id>/accept-anyway, …/redact-evidence | review |
attempts.redactEvidenceBefore({ quest, decidedBefore, reason }): 100 per call, repeat while more | POST /attempts/redact-evidence | review; in live, an organization admin's Console session or personal key, or a service key |
attempts.evidenceRetention(), attempts.setEvidenceRetention({ evidenceRetention }) (experimental): how long the project keeps submitted evidence in both environments, { days } (1–3650, 90 by default) after the decision or "keep", and noticeEndsAt; Domino's hourly job deletes due evidence (domino attempts evidence-retention, domino attempts set-evidence-retention --days N or --keep) | GET, POST /evidence-retention | read; changing it publish, and shortening it an organization admin's Console session or personal key, or a service key |
completions.revoke(completion, { reason, freeOccurrence }), completions.restore(completion, { reason }); the ({ completion, … }) form also works | POST /completions/<id>/revoke, …/restore | review |
passes.verify({ provider, event, subject, member, quest }) | POST /passes/verify | confirm |
handover.begin(entitlement, { revision }), handover.confirm(entitlement, { handover }) | POST /entitlements/<id>/begin, …/confirm | handover |
handover.cancel(entitlement, { revision, reason }) | POST /entitlements/<id>/cancel | handover |
entitlements.void(entitlement, { revision, reason }), entitlements.restore(…), entitlements.correctCollection(…) | POST /entitlements/<id>/void, …/restore, …/correct | review |
quests.pause/resume(quest, { revision, reason }) and redemptions.pause/resume({ revision, reason }) | POST /quests/:id/pause or /resume, and /redemptions/pause or /resume | publish |
groups.list({ cursor, limit }), groups.get(group), groups.members(group, { cursor, limit }), groups.member(group, member) | GET /groups, GET /groups/<key>, …/members, …/members/<id> | read |
groups.create({ key, title }), groups.update(group, { title }), groups.archive(group, { reason? }), groups.unarchive(group, { reason? }) | POST /groups, POST /groups/<key>/update, …/archive, …/unarchive | publish |
groups.addMember(group, { member, reason }), groups.removeMember(group, member, { reason }) | POST /groups/<key>/members, …/members/<id>/remove | publish |
inventory.addStock({ item, quantity }) | POST /stock | inventory |
offers.list(), redemptions.history({ member, cursor, limit }) | GET /offers, GET /redemptions/history | read |
offers.control(id, { revision, enabled, reason }) | POST /offers/<id>/control | inventory |
offers.cancelRedemption(id, { revision, reason }) | POST /redemptions/<id>/cancel | inventory |
quests.updateSettings({ quest, expectedRelease, set, reset, solanaIdls? }) | POST /releases/settings | publish; test only |
tokens.list(), tokens.create(input), tokens.scope(id, input), tokens.revoke(id) | /tokens | a person's session, never a key |
grants.list(), grants.set({ subject, roles, environments, expiresAt? }), team.list(). Experimental: the viewer role (the project's dashboards only) and expiresAt (epoch ms; null for no end), after which a grant gives nothing and stays listed. A grant's roles keeps the 1.x roles and projectRoles lists them all; access() projects list dashboards only in projectPermissions | /grants, /team | organization administrator |
identityIntegrations.list(), create({ name, origin, expiresInDays? }) (origin: an exact HTTPS origin, or on a test key a loopback one such as http://localhost:3000; live refuses loopback with 400 invalid-request), addSecret(id, { expiresInDays? }), revokeSecret(id, secret), removeSecretExpiry(id, secret), revoke(id); deprecated rotate(id) | /identity-integrations | admin, Console; admin CLI reads |
identityIntegrations.createIntoAppSecret(input, name), addSecretIntoAppSecret(id, name, { expiresInDays? }): store the new secret as the hosted app secret name in the key's environment, answer appSecret and no token; a refused app secret leaves no key or secret | POST /identity-integrations, POST /identity-integrations/<id>/secrets with appSecret | admin, Console; publish |
identityIntegrations.setAccountVouching(id, { enabled, reason, actionId? }) | PUT /identity-integrations/<id>/account-vouching | admin, Console; publish |
serviceKeys.list(), create({ name, permissions, expiresInDays? }), addSecret(id, { expiresInDays? }), revokeSecret(id, secret), revoke(id) | /service-keys | organization administrator, Console |
For test scenarios, management.testMembers.history(member, { since }?) (domino test-members history MEMBER [--since MS]) reads attempts with their ledger-credited eligibleAt and skipped tracker outcomes with their reason code and occurredAt. It needs publish permission in the test environment, like the other test-member methods. See Try quests with test members for the window and limits. management.testMembers.redeem(member, { offer }) (domino test-members redeem MEMBER OFFER) redeems an offer as the test member and answers the Redemption a member's own redeem answers.
Inputs fenced by revision (quest and redemption pauses, offers.control, cancelRedemption, handover and entitlement corrections, the pickup policy, and the deprecated account unlinks and unlinkCreatorAccount) also take it as expectedRevision, the name newer methods use. Pass either; when you pass both they must agree, or the call is refused with 400.
The management client never reads an integration key's secret. To check which integration a di_ key belongs to, its holder calls inspectIntegrationKey from @domino-sdk/relay/portal-proxy; @domino-sdk/relay/server exports its answer's schema as identityIntegrationKeySchema (participant API).
memberImports.check|apply|cancel|stop(id, { actionId }?) and promotions.approve(id, { actionId }?) take an optional input before the call options, and promotions.reject, members.unlinkCreatorAccount and identityIntegrations.setAccountVouching take actionId in theirs: a retry with the same one answers with the first result instead of a refusal for the state the first call left.
Methods whose input has an actionId generate one when you leave it out. Every method takes an optional last argument { signal, timeout }: signal cancels the call and timeout is its deadline in milliseconds, which also bounds your getToken. The client's own timeout option sets the default, 30 seconds unless you choose another. For typed methods the deadline covers every attempt, the waits between them and the answer's body; for raw it covers only the wait for the response, so read a stream with your own signal. Cancelling stops any retry at once and never undoes a write already sent: that write stays uncertain.
Typed methods send a call again, at most three times in all, when the server applies a repeat at most once: every read, and every write whose input has an actionId, which the server keeps a receipt for and answers with its first result. Every attempt sends the same payload. They retry when no answer arrived, when the answer broke off after its headers, on 500, 502, 503 and 504, and on a 429 whose Retry-After fits in the time left, waiting Retry-After when the server sent one and otherwise up to 0.25 seconds, then up to 1 second. A timeout, a cancellation, an unreadable answer, rejected input and any other refusal are never retried. Writes without an action ID (such as secrets.set, grants.set or pointLedgers.update), request with any method but GET, and raw are sent once, unless the answer is 503 with Retry-After: Relay answers that only to a request it refused before doing anything, so every call is sent again after it. If an error body is cut off, its status still controls retries and its Retry-After is kept in retryAfter. When retries run out, the last failure is the rejection, with retryAfter set to the wait in milliseconds the server's Retry-After asked for; a write any attempt of which may have been applied stays uncertain.
completions.revoke takes back an accepted completion in one transaction and resolves with what it did. The completion stops counting for progress, prerequisites of new attempts, counts, profiles, and referral qualification. Completions it already unlocked stay. With freeOccurrence: true it returns to each quest budget what the completion was charged (budgets: budget, released); a point-priced budget keeps the cost of points the revoke couldn't reverse. Otherwise budgets stay spent.
points lists each award with the amount reversed and the amount unreversed because the member already spent or reserved it. Relay persists that remainder for each point ledger on the revoked completion, includes it in the audit event, and never records member debt for it. The member keeps the value unless an operator records a separate points.record({ kind: "adjust", ... }) correction. Console links the warning to that existing action.
entitlements lists each tiered reward it granted as voided (a handover in progress is cancelled first), downgraded to the best tier another completion grants, kept, or flagged when it was already collected. deliveries lists the completion's original Discord role grant outcomes. A delivered one is flagged for asynchronous membership reconciliation. A pending one is stopped as cancelled, or as in-flight when a call may already have reached Discord and can still land. A failed one is not-delivered. stillGranted says whether another accepted completion across any project or environment also grants the role to the same account and server. Read the delivery's reconciliation for current provider state. Relay removes only its own assignments after the last grant is revoked.
referrals lists programs that qualified on the completion as unqualified (the inviter's reward is reversed) or requalified on another completion of the quest. streakMilestone is true when the revoke removed a streak milestone. The days stay. source ({ kind, id }, as on completion.created) says whether it was a quest completion or a leaderboard prize, whose quest carries the board ID. freeOccurrence: true lets the member complete the quest again for the same completion key, which pays again. Unreversed points stay with the member. By default, later attempts for the occurrence are rejected.
Outcomes are open strings. The known values are RevokedEntitlementOutcome, RevokedDeliveryOutcome, and RevokedReferralOutcome. A second revoke fails with 409 completion-revoked. An unknown completion fails with 404. Find a completion's ID on its accepted attempt (completion) or in a member's snapshot.completions from members.get(id).
members.get(id) also returns every active quest release with its evaluator code in snapshot.releases, megabytes in a project with many quests. Code that only needs a member's quest state reads members.progress(id) (domino members progress MEMBER): member and quests as members.get(id).progress has them, the 25 most recent attempts, newest first, and point balances. Its lists are bounded by the project's configuration: each quest's streak leaves out its days, and rewards, completion records and streak days stay in members.get. Read linked wallets with members.identityStates.list(id).
members.get(id).revocationShortfalls returns up to 25 revoked completions with a non-empty point remainder, newest first. Each item contains completion, quest, revokedAt, and unreversedPoints keyed by point ledger ID. revokedCompletions lists up to 25 revoked ones; completions.restore re-applies a revoke's recorded result. referral is the member's accepted referral, { inviter, acceptedAt, suspended, import } (import names the member import that recorded it, else null), or null when nobody invited them; Relay versions before imported referrals leave it out.
Missing completion reward history returns 409 completion-grant-history-missing, naming the completion whose history Domino support must restore before retrying. Only older completions whose original record could not be read lack it. Shared tier grants also require history for every other accepted completion of the member. The refusal precedes member-state changes. Collected entitlements retain recovery: { completion, reason, flaggedAt, remainingTier } in normal reads until an operator corrects collection. Correction voids the reward when remainingTier is null, or makes the remaining earned tier available, and records the resolved flag in history. A later grant updates eligibility without clearing the pending recovery.
Entitlement corrections take the revision you read and a reason, and Console Audit history records each one. handover.cancel returns a handing-over entitlement to available, entitlements.correctCollection does the same for one recorded collected by mistake, and both put a pickup size back into stock. entitlements.void turns an available entitlement void; a quest that grants the same reward to the member again makes it available, as a fresh grant at that quest's tier; a voided referral reward stays void until restored. entitlements.restore (CLI domino entitlements restore) undoes an operator's void, at the tier an accepted completion still earns and with the referral reviews the void settled. 409 entitlement-status refuses it when a revocation voided it or no accepted completion earns it (completion names what to restore), after a redemption cancel, and for a referral reward voided before voids kept reviews. An entitlement whose status this SDK does not know reads as "unsupported" with reportedStatus; offer no action on it. A stale revision fails with 409 and code entitlement-changed, and the wrong starting status with 409 entitlement-status. These are member-state corrections, so an API key with the permission can make them in live.
members.lookup({ kind, subject }) finds the one member of the client's project holding exactly that account, named as a member import names it: { kind: "x" | "discord" | "telegram", subject } with the numeric user ID, { kind: "app:<name>", subject } with a linked user ID, or { kind: "wallet", chain, address } with the address as the member's accounts list it. { provider, subject }, with provider and subject as a member's accounts list them, is the deprecated form, removed in 2.0.0; it still finds accounts of other providers, such as email. The lookup never matches part of a value or another letter case. When nobody holds the account it fails with 404 and code member-not-found; when several members do, with 409 account-ambiguous and their IDs in details.members. Prefer it to members.list({ q }) when you know the account.
An account Relay reads in the background with the member's own OAuth tokens also carries status: connected while the tokens work, needs-reconnect once they stopped, with a reason of revoked, expired or missing-scope. Other accounts carry neither field. Only the member clears needs-reconnect, by linking the same account again; see participants.
members.list({ q?, needsReview?, limit?, cursor? }) (GET /members/list, domino members list) needs read and returns { items, total, nextCursor }: the project's members, most recently joined first, each with accounts, appIdentities, attempts, completions, rewards and lastActivity, and total, how many match. q matches part of a member ID, a profile's display name, an account's subject, name or email, or an app identity's subject, ignoring letter case, or a profile's public ID exactly. needsReview: true keeps members with an attempt waiting for review. limit is 1 to 100, 25 by default. Pass nextCursor back as cursor for the next page until it is null; a cursor works only with the q and needsReview it came from, and any other answers 400 invalid-cursor.
Other reads of a project's state need read and page the same way, newest first, with total beside items (limit 1 to 100, 25 by default; a malformed cursor answers 400 invalid-cursor):
attempts.list({ q?, quest?, status?, limit?, cursor? })(GET /attempts):qmatches part of the member, quest or attempt ID;questkeeps one quest's attempts, by its ID. Besideitems,releasesare the releases the attempts were admitted under andpeoplethe members they name, with their accounts.attempts.get(attempt)(GET /attempts/:attempt) answers{ attempt, release, events, person }: its release (null if gone), up to 200 recorded events newest first, including a revocation of the completion it reached, and its member; an unknown attempt is 404.entitlements.list({ q?, status?, limit?, cursor? })(GET /entitlements):qmatches part of the member ID or reward title;peoplesit besideitems.entitlements.get(entitlement)answers{ entitlement, person }, or 404.audit.list({ category?, q?, limit?, cursor? })(GET /audit/list): reviews, reward changes, configuration and operators acting as test members (categorykeeps one:reviews,rewards,configurationortest-members) in the client's environment;qmatches part of the actor, action, details or attempt ID.audit.access({ q?, limit?, cursor? })(GET /audit/access) pages the project's access and API key changes across test and live, for an organization administrator's Console session or CLI sign-in only.
Release history needs publish as well as read, because releases carry each quest's code. releases.list({ quest?, limit?, cursor? }) (GET /releases/list) answers { items, nextCursor }: every release the project's quests have had, current and earlier, newest first, without total. quest keeps one quest's releases, by its ID or key, and a cursor works only with the quest it came from. releases.get(release) (GET /releases/:release) reads one release by ID, current or historical, or answers 404. A release's integrations and rewards keep SDK 1.x's closed types; integrationModules lists every declared integration module and rewardEntries every reward as { type, data }, including kinds the SDK does not know. Both are absent from older Relay versions.
Small collections come whole: metrics.read() (GET /metrics) answers the project's counts (quests, members, attempts, completions, review, failed, entitlements) and completionsByQuest, each quest's counting completions as { quest, completions }; quests.list() (GET /quests/list) each quest's current release, most recently published first, archived quests included (every release read leaves secret settings out of values and configuration, and lists the set ones in withheldSettings); operations.list() (GET /operations) every paused or resumed operation; and inventory.list() (GET /stock-items) every stock item with available, reserved and allocated counts and the offers that sell it.
metrics.read() counts all time. For the same records over time, analytics.read({ range, ledger? }) (GET /analytics?range=7d|30d|90d|all, read permission, domino analytics get) answers each metric's total, its value per project-calendar day or week, and the previous range's total; see project analytics for what each metric counts.
usage.activeMembers() (GET /usage/active-members, read permission, domino usage active-members) answers the project's live active members this UTC month against its plan: { month, activeMembers, plan, limit, history }, where limit is null when the plan has none and history holds the six earlier months, newest first. The count is live only, whichever environment the client selects. An active member sent a signed-in participant request on a UTC day of the month after the day they joined; see active members.
page(name, query) reads Console's former screens and is deprecated: compose these reads instead (releases.list and releases.get replace its releases page). It is removed in 2.0.0.
Every failure is a ManagementError, including input the client rejects before sending. Check kind first:
kind | Meaning |
|---|---|
http | The server refused the call. message and code are the server's own. |
network | The connection failed, before the response or while its body, an error body included, was read. |
timeout | The deadline passed, retries included. |
aborted | Your signal cancelled the call. |
invalid-response | The server answered, but not with JSON this client version recognizes. |
client | Nothing was sent: the input or options were invalid, or your getToken failed. |
status is the HTTP status whenever the response headers arrived, including a network or timeout failure while the body was read; otherwise it is absent. With kind http, code is the server's own code, including ones newer than your client, so compare it only against codes you handle; otherwise it is the client's (network, timeout, aborted, invalid-response, invalid-input, credential). details holds the rest of the error body when there is any, such as moved for PROJECT_MOVED, or { record, path, reason } when trackers.ingest refuses a batch with invalid-record-data. method and path name the call once a request was formed. ManagementError extends the SDK's RequestError, the same core as the participant client's RelayError; isManagementError(error) and isRequestError(error) recognize them across copies of the SDK.
When uncertain is true, a write may have been applied: it was sent and then timed out, was cancelled or lost its connection, was answered 500, 502 or 504, or was answered 2xx with a body this client cannot read. A 503, a 429, another refusal, a failure while getToken runs, a connection that was refused or never resolved (so nothing was sent), and every read are never uncertain. operationId is the actionId the request carried, and recovery says what to do: replay means repeat the same call with the same payload and operationId, which the server applies at most once; reconcile means read the resource before deciding. Generic request and raw writes report reconcile even when the caller supplies an actionId, because the client cannot know whether that route keeps receipts. Typed writes without an action ID also require reconciliation.
Experimental billing
management.billing and the billing schemas and types (billingCheckoutInputSchema, BillingSubscription, ProjectBilling and the rest) are experimental while billing is open to Domino staff only: they, and the plan names, may change in a minor release. management.billing.checkout() and changePlan() take any plan name (BillingPlan); Relay refuses one it does not offer with 400. The billing results read a plan as BillingPlan too (subscription.plan and scheduledChange.plan), so handle a name this SDK does not know. management.usage.plan() is stable, and the subscription it carries keeps the plans this SDK knows.
Experimental plugin connections
management.connections connects the project to plugins, the integrations Domino writes, in the selected environment. Each environment has its own connections, and a promotion never copies them. It and the connection schemas and types (connectionSchema, Connection, ConnectionConnectInput and the rest) are experimental while the plugin contract is new: they may change in a minor release. list() answers every plugin available to the project as { items, nextCursor }, a never-connected one as disconnected with revision 0; get(plugin) answers one, and 404 unknown-plugin for a plugin Relay doesn't have. Each connection has status (disconnected, ready, attention with issue saying why, or a status this SDK doesn't know), config, the JSON Schema it must satisfy (configSchema), its secret fields with whether each is set, and revision, which every change moves, a plugin's status report included. Secrets and OAuth tokens are write-only: no read, error or log answers a value.
connect(plugin, { config, secrets, actionId? })needs a value for every secret field. 422invalid-confignames the failing field inpath, 422invalid-secretsnames a missing or unknown field infield, and 409already-connectedmeans change it withupdate.update(plugin, { config?, secrets?, expectedRevision?, actionId? })replaces the whole config, the secrets it names, or both, and sets the status back toready. 409not-connectedon a disconnected plugin; 409connection-changedwhenexpectedRevisionis stale.disconnect(plugin, { expectedRevision?, actionId? })deletes the secrets and keeps the connection asdisconnected;connectworks again. Disconnecting a disconnected plugin changes nothing. An OAuth connection's tokens are then revoked at the vendor, when the plugin names a revocation endpoint; a failed revocation is logged, and Domino holds the tokens no more either way.authorize(plugin, { config?, returnTo?, actionId? })is for a plugin whoseplugins.list()entry hasoauthset: it connects through the vendor's OAuth instead of entered secrets, andconnectrefuses it with 409oauth-required. It answers{ authorizationUrl, expiresAt }; a person opens the URL within ten minutes and grants access, and the vendor sends the browser back to Console withconnection,oauth=pendingand a one-timeoauthCodein the query (oroauthdeclined,failedorinvalid-state).completeAuthorization(plugin, { code })redeems that code within five minutes and answers the connection,readywithconfig; Console does this on its own. Only the person who calledauthorize, on its project and environment, finishes it: anyone else gets 403scope-mismatchand the code is spent, so a link sent to someone else can't connect their vendor account to your project. A spent, expired or unknown code is 409oauth-flow-expired, a repeat after a lost answer included (read the connection to see whether it finished), and the vendor refusing its code 502upstream-failed; either way,authorizeagain. The URL works once.returnTomay name a Console page to land on; without it the browser lands on Console's Integrations page for the project, and any other origin is 400invalid-request. An automation, service or hosted app key can't authorize (403credential-not-allowed). 409oauth-not-supportedfor a plugin connected with secrets, 409plugin-not-configuredwhen this deployment lacks the vendor's OAuth app, 422invalid-config. Domino refreshes the tokens before they expire. When the vendor refuses a refresh, or the tokens can no longer be refreshed, the connection isattentionwithreconnectReason(revokedorexpired, open for more) and anissue;updatedoesn't clear that, andauthorizeagain reconnects it, keepingconnectedAt.connect(plugin, { config, dryRun: true })connects a destination whose manifest allows a dry run without secrets or the vendor's account, in the test environment only: the plugin records each request it would have sent instead of sending it, and the connection readsdryRun: true. 409dry-run-not-supportedfor any other plugin, 409dry-run-test-onlyin live. Authorizing the vendor's account over a dry run turns it off and starts the backfill again.- For a destination (a plugin with the
destinationslot),activity(plugin)answers its delivery record:lastSyncAt,failingSince(set after 10 failed deliveries in a row, or a day of failing deliveries without one success, and cleared by the next delivery the vendor accepts),consecutiveFailures,members(synced, andskippedperreasonsuch asno-emailorno-consent),sync(the latest backfill or resync:kindconnectorresync,statusrunning,finishedorfailed,doneoftotalmembers), and the latest 20errors, newest first.resync(plugin, { actionId? })sends every member's profile and attributes again, never past events, and answers the sync; one runs at a time, so while one runs it answers that one.dryRunLog(plugin, { cursor?, limit? })pages through what a dry run would have sent, newest first ({ items, nextCursor }, each withmember,method,pathandbody); Domino keeps the latest 500. All three answer 409not-a-destinationfor any other plugin, andresync409not-connected. - A plugin that connects through its own flow, such as Telegram, whose chats connect through Domino's bot (
management.telegram), has no settings or secrets. Its status comes from what the project connected there:readyonce it has a chat in this environment,attentionwith theissueof a chat Domino's bot can't read,disconnectedbefore.connect,updateanddisconnectanswer 409plugin-managedfor it.
management.plugins.list() (experimental too, with pluginSchema, pluginPageSchema, PluginInfo and PluginSlot) answers the plugins this deployment runs as { items, nextCursor }, by id, each with the public part of its manifest: name, slots (connection, destination, announcement, tracker-source, evaluator-capability, identity, or a slot this SDK doesn't know), the config's JSON Schema (configSchema), its secret fields (name, label, help), the Domino event types a destination receives (events), and oauth: null for a plugin connected with secrets, otherwise the scopes it asks the vendor for and whether this deployment has the vendor's OAuth app (configured). console is { component } when Console shows a bespoke component for the plugin instead of the generated card, otherwise null. It needs read.
In Console, Integrations shows a card per plugin; opening it shows a panel with a form generated from that manifest, or the plugin's own component, such as Telegram's chats: connect with the settings and secrets, Edit the settings or replace a secret, Disconnect, and Fix connection when it needs attention. An OAuth plugin's card has Connect with instead, which asks for the settings and then sends you to the vendor, Reconnect when its tokens stopped working, and shows Not configured when the deployment lacks the vendor's OAuth app. Secrets show only as set or not set. Without publish the card is read-only. Each plugin also has a reference page under Integrations in these docs, generated from its manifest.
Reads need read and changes publish. A CLI session may change connections in live too; an automation key or MCP client changes them in test only. From a terminal: domino connections list|get|connect|authorize|update|disconnect|activity|resync|log (test unless --environment live); authorize prints the authorizationUrl to open, connect --dry-run makes a dry run, and log reads its requests. connect and update --secrets read the secrets as one JSON object on stdin, or at a hidden prompt per field, never from arguments; --config-json takes the config.
Project identity and settings
management.access() includes each project's createdBy user ID when recorded and creator details when available. Audit records include user details for resolved operators while preserving actor. Missing users or unavailable lookups keep the original actor ID.
Organization admins use Console Settings → General to rename a project (Project name) and change its hosted test address (Project slug → Change slug). The management client's updateProject({ name }) or updateProject({ slug, confirm }) calls PATCH /project; confirm is the current project name. A new slug needs 3 to 35 characters and cannot be a name Domino reserves (exact match, such as console, api, or an active legacy portal): a reserved slug returns 409 Project slug is reserved, a shorter one 400. New projects never receive a reserved or shorter slug; the derived slug gets a short suffix instead. These writes require a Console session, like deleting and transferring a project. CLI sessions and API keys cannot make them. Names change without changing the slug. Slug changes affect both environments and existing staging links; update address references and reopen previews. Changes appear in the admin-only Access & API keys audit history. See project settings.
Resource lifecycle
Use domino resources list --json to inspect active and archived trackers, referral programs, leaderboards, engagement rules, collection slots, quest types, images and point ledgers. domino collections list --json lists only collection slots, archived ones included, as { collections: [{ id, key, title, archivedAt }] }. Archive or restore by key or ID:
domino trackers archive events --reason "Source retired" --environment test
domino trackers unarchive events --reason "Source returned" --environment testAccepted kinds: tracker, referral, leaderboard, engagement, collection, quest-type, image, point-ledger. Read needs read; mutations need publish, an action UUID and a reason. Resource-specific commands accept --action-id UUID; retain that UUID and repeat the identical command after an interruption. The deprecated resources archive/unarchive commands save a journal and recover through domino operations resume <uuid>. Live refuses archive and restore from every credential, Console included, with promotion-required. A quest instance retains its separate quests archive|unarchive commands.
Management code uses management.resources.list() and management.trackers.archive(reference, { actionId, reason, expectedArchivedAt }) and management.trackers.unarchive(reference, { actionId, reason, expectedArchivedAt }). Use unarchive to restore. Optional expectedArchivedAt fences the change against the timestamp or null the operator read. A different state returns a conflict; read again before deciding.
Archive retains IDs and historical data. Trackers stop new collection and ingestion; referral programs stop qualification and bonus accrual; leaderboards and collection slots leave participant reads; quest types refuse new instances while existing quests work; images refuse fresh named captures while stored evidence replays; engagement rules stop settlement but retain watermarks. Restoring engagement rules can pay growth since those watermarks. Archived point ledgers reject new definitions but keep existing rewards and balances working; restoring makes the ledger selectable again. Archived offers (domino offers archive) leave member reads; redeeming fails with offer-paused. Archived tiered rewards (domino tiered-rewards archive KEY --reason …, management.tieredRewards.archive, both experimental) stop new grants: a deploy or promotion that would make a quest, offer or leaderboard prize newly grant one fails with 422 tiered-reward-archived. What already granted it keeps granting it, a new version included; entitlements, handover and participant reads keep naming it; its key stays taken. resources list and tiered-rewards list list them in tieredRewards. Badge and group rewards are not tiered rewards here.
Archive state is a definition. Deleting declarations, redeploying or rolling back a code deployment never implicitly archives or restores a resource; a promotion carries test's archive state to live. Lifecycle changes invalidate earlier catalog previews. Tracker configuration and point ledger names/descriptions remain current operator state, while captured evaluator facts and accounting entries preserve history.
Canonical lifecycle commands
Use the resource's archive, unarchive, pause or resume SDK method. The CLI commands use the same verbs: trackers, point-ledgers, referral-programs, leaderboards, engagement, collections, quest-types and images support archive/unarchive; quests and groups keep their archive/unarchive commands. Trackers, quests, offers, webhooks and referral-relationships support pause/resume. redemptions pause|resume controls all collection. Quests, offers and redemptions require --expected-revision (--revision, deprecated, still works); quests, offers, referrals and redemptions require --reason.
The older trackers.control, operations.configure, offers.control, webhooks.disable, referrals.suspend, resources.lifecycle and body-ID /releases/lifecycle remain supported through 1.x, deprecated for removal in 2.0.0. Use deployments for code publication. Catalog deployment wrappers and engagement-only publish/change/previewChange methods are deprecated. So are offers.redemptions(), which answers every redemption at once, and deployments.list(), which stops at the newest 100: use redemptions.history and deployments.history, which page by cursor. Console draft publication and quest settings overrides stay separate. The low-level /releases and /releases/batch routes stay supported without new SDK wrappers; use deployments.
Recover a quest lifecycle change
domino quests archive and domino quests unarchive save the action ID, reason, expected release and scope before sending. After an uncertain response, run the error's domino operations resume ID command. It reuses that request and returns its committed result. Use domino operations list --json to find saved operations after restarting the CLI. A new archive/unarchive command without --operation-id starts a new operation. See CLI automation.
Restrict an ingestion key
In Console Settings → API keys, choose Ingest, then Selected trackers only. The key stores immutable custom tracker IDs from that project and environment. A tracker outside the list returns 403 TRACKER_SCOPE_DENIED before ingestion. Omitting ingestTrackers or setting it to null keeps project-wide ingestion; an empty list is invalid. Hosted app keys remain project-wide. Edit tracker scope replaces the list without rotating the secret.
A Console session or an unscoped human CLI sign-in can manage personal keys; API keys and project-scoped playground sessions cannot. A key expires after days (1–90, default 30; Console always uses 30). With a human CLI sign-in, domino tokens create --name NAME --permission ingest --ingest-tracker TRACKER [--days N] issues one for the selected project in test; a CLI sign-in creates and scopes no live key (403 console-approval-required), so ask the user to create a live key in Console Settings → API keys; domino tokens list shows each key's expiresAt (with --json, the keys are in items, and in tokens as before); domino tokens scope ID --ingest-tracker TRACKER (or --clear) and domino tokens revoke ID edit them; ID is the key's ak_… ID from the listing, and anything else is 400 invalid-request. Keep the one-time token in the indexer's secret store. After an uncertain creation, list and revoke the unconfirmed key before issuing another; after an uncertain scope edit, list the key and reconcile its ingestTrackers before retrying.
Service keys for a self-hosted backend
A backend that should keep working when people leave the organization uses a service key (dsk_…) instead of a personal key. An organization admin creates it in Console Settings → API keys → Create a service key for one project and environment, with chosen permissions (never access) and no expiry unless one is chosen. Its actor is service:<key ID>. It gets every key fence: it cannot approve live publication, manage test members, mint keys or use /tokens. Rotate without downtime with Add secret, deploy the new secret, then Revoke the old secret once its Last used stops changing. Agents cannot create service keys; ask the user to create one in Console and store it as DOMINO_MANAGEMENT_KEY in the backend's secret store.
Operator undo commands
Read the current state before correcting it. Each new mutation accepts an optional action ID, an observed revision or settlement creation time, and an audited reason. Keep the exact action ID and input for an identical retry. Console exposes the same controls; a CLI session cannot make these corrections in live. Renaming, archiving and restoring a stock item are definition changes that live refuses with promotion-required; stock corrections work in live.
| Action | SDK and path | Permission |
|---|---|---|
| Read quest enrollment | quests.enrollments(member), GET /members/:member/quest-enrollments | read |
| Withdraw quest enrollment | quests.withdraw(quest, member, { expectedRevision, reason }), POST /quests/:quest/enrollments/:member/withdraw | enroll |
| Read inventory state | inventory.items(), GET /stock-items/states | read |
| Rename, archive or restore an item | inventory.rename/archive/unarchive(item, { expectedRevision, reason, name? }), POST /stock-items/:item/{rename,archive,unarchive} | inventory |
| Correct free stock | inventory.adjustStock(item, { expectedRevision, reason, delta }), POST /stock-items/:item/adjust-stock | inventory |
| Read settlement decisions | leaderboards.settlementReviews(board, { cursor, limit }), settlementReview(id); GET /leaderboards/:board/settlement-reviews, GET /leaderboard-settlements/:id/review | read |
| Export a settlement's payout list | leaderboards.settlementExport(id), GET /leaderboard-settlements/:id/export; settlementPayoutCsv(result) formats it as CSV; domino leaderboard-settlements export SETTLEMENT prints CSV, --json the rows | read |
| Reject a pending or closing settlement | leaderboards.rejectSettlement(id, { expectedCreatedAt, reason }), POST /leaderboard-settlements/:id/reject | review |
| Measure a settlement's unmeasured content again | leaderboards.retrySettlementMeasurement(id, { actionId? }), POST /leaderboard-settlements/:id/retry-measurement; refuses a pending settlement with nothing unmeasured (settlement-measured) | review |
| Read a member's identities | members.identityStates.list(member), GET /members/:member/identity-states | read |
| Unlink or relink an identity | members.identityStates.unlink/relink(member, { identity, expectedRevision, reason }), POST /members/:member/identity-states/{unlink,relink} | review |
| Turn wallet sign-in on or off | members.identityStates.setSignIn(member, { identity, enabled, reason }), POST /members/:member/identity-states/sign-in | review |
CLI equivalents:
domino quests enrollments MEMBER,domino quests enroll QUEST MEMBER,domino quests withdraw QUEST MEMBER --expected-revision N --reason TEXT.domino stock-items list;rename ITEM NAME,archive ITEM,unarchive ITEM, oradjust-stock ITEM DELTA, each with--expected-revision N --reason TEXT.domino leaderboard-settlements list BOARD --limit 25,get SETTLEMENT,export SETTLEMENT(CSV payout list;--json),reject SETTLEMENT --expected-created-at TIME --reason TEXT.domino members identities MEMBER, thendomino members unlink-identity MEMBER change.jsonorrelink-identity MEMBER change.json. The file containsidentity,expectedRevisionandreason. Name the identity as a member import does:{ kind: "x" | "discord" | "telegram", subject }with the numeric user ID,{ kind: "app:<name>", subject }, or{ kind: "wallet", chain, address }(a Cosmos wallet may carry itsnetwork). An X or Discord subject names the member's selected account with that subject, else the only one they hold; when they hold it through several issuers and none is selected, the command fails with 409identity-ambiguous, so copy the selector from the list instead. The list's selectors still work:{ kind: "wallet", chain, address },{ kind: "telegram", subject }, and the deprecated{ kind: "oauth", provider, issuer, subject }and{ kind: "app", appKind, subject }, removed in 2.0.0.domino members identity-sign-in MEMBER sign-in.jsontakesidentity(a wallet),enabledandreason.members identity-linksandmembers relinkare their deprecated wallet and OAuth forms.- Each identity carries
source:member(the member proved it),import(a member import attached it, with the import's ID asimportwhen Relay recorded it),app-login(an app login's server vouched for it at sign-in) ortest-link(an operator linked it to a test member). Treat other values as unknown sources; Relay versions before it leave it out.members.get'swalletsandidentities, and the deprecatedoauthAccountsreads, carry it too. - An X, Discord or Telegram account or wallet a member import or an app login vouched for lists with
imported: trueuntil the member links an account of that kind themselves. Unlinking one the member never signed in with or proved releases it: the answer shows it unlinked withcanRelink: false, its owner can link it, and imports and vouching refuse it for that member withidentity-unlinked. Otherwise unlinking and relinking it work as for a proved account.
Quest withdrawal preserves admitted attempts, completions and rewards. Re-enrolling after withdrawal sets a new consent time; repeating an active enrollment keeps that enrollment's time. This differs from campaign enrollment, whose original time survives withdrawal.
Inventory corrections change free stock and cannot consume reserved or redeemed units. Rename preserves frozen allocation labels. Archive refuses with inventory-in-use while any offer or pickup variant references the item, naming them in the refusal. Paused and archived offers still block; archive the offer instead. Reconfigure pickup references away first. Archived items refuse new allocation, restocking and pickup configuration with inventory-archived; unarchive remains allowed.
Settlement rejection is final for that frozen window. It keeps winners and exclusions inspectable and grants no prizes. It cannot reject an approved settlement or re-freeze the window. New review reads include rejected settlements; legacy reads keep their original pending/approved contract.
Unlink and relink dispatch by identity kind; ownership never changes, except that unlinking an imported account or wallet the member never signed in with or proved releases it. Wallet links belong to the project, preserve their proof and first-link time, and keep sign-in unchanged. X, Discord and Telegram selections belong to the project and environment too; relink only an exact retained owned account, after unlinking any current selection of that provider. Account relink starts a fresh link time and preserves sign-in grants and existing sessions. App identities sign in only while linked: unlinking one ends the sessions its integration started for it. Every relink redecides unlinked-account history using the current quest policy: automatic can admit it; claim or staff leaves historical activity assigned to the member. Admitted attempts remain pinned. Wallet sign-in has its own command and never follows a link change. A stale expectedRevision, or unlinking an unlinked identity, is refused with 409 account-changed; an identity the member never proved is 404.
Inventory states, quest enrollments and retained identity choices are cursor pages: read items and pass nextCursor as --cursor until it is null. Each accepts --limit (1–100, default 50). Console's Inventory items includes stock owned by offers; Load more inventory items, Load more enrollments, Load more owned identities and Load more settlement windows reach later pages.
Scheduled hosted jobs
Declare app.schedules beside secrets and storage in a Worker app's relay.json. Stage the app to activate signed POST jobs in test, and verify them with verifyScheduledRequest(request, env.DOMINO_SCHEDULE_KEY) before doing work. Use a scheduled route to refresh custom trackers through hostedManagementClient(env).trackers.ingest, with the verified run ID as the ingestion action ID, in one batch per tracker. Answer with scheduleResult({ outcome, summary, metrics, diagnostics }) so client.schedules.runs(name) and domino schedules runs NAME show what each run did. See hosted schedules for limits, results, visibility and uncertain-run recovery.
Schedule dispatch guarantees
People's guide: Timeouts and recovery.
- At most once. Relay attempts each automatic tick at most once. It commits the tick's receipt before sending and never retries automatically. Missed ticks during downtime are skipped; dispatch resumes with one due run and computes the next future occurrence.
- Failures permit the next tick. A failed app HTTP response, or a result with a
partialorfailedoutcome, records a failure and permits the next occurrence. An explicit bridge rejection before delivery, such as a credential mismatch or a build with no Worker to run it, also fails that tick and permits future occurrences; the run'serrorincludes the rejection's reason. Other bridge failures remain uncertain. - Serial calls. Calls from one project run serially, so the same schedule cannot overlap itself. Activation waits for an in-flight dispatch before replacing the build. A replacement build resets the next occurrence from its activation time.
- Uncertain runs block. If delivery or completion is uncertain, Relay blocks that schedule, including across build replacement. A dispatcher crash after admission also leaves it blocked. After the 30-second timeout, Console shows Uncertain and offers Recover uncertain run;
domino schedules recover NAME --yesandmanagement.schedules.recover(name, { stopped: true })do the same. Recovery allows future ticks and never replays the uncertain tick. If a dispatcher disappeared, the run's duration is unknown. An uncertain run'serrorsays what Relay saw: the dispatch bridge's HTTP status and cause, no answer within the timeout, or the error that interrupted the send. An exception the app throws before answering is recorded there, name and message trimmed to 300 characters, so keep secrets out of thrown messages as well as results.