Read a level
GET /v1/users/:pid/level returns the authoritative summary for any live
(non-deleted) identity. It is readable with any authorized credential — a
session cookie or a machine token — including apps that are not verified and
have no XP permission. Permission gates the event-intake path only, never reads.
Unknown or deleted PIDs answer
404 with the standard error envelope. Users
with no XP history read as Level 1 / 0 XP (the state row is created lazily on
first read — no ledger entry). GET /v1/profile/me also carries the same
summary as its optional level field.
SDK helper
Level badge (React)
@peridotvault/pid-react-embed ships a drop-in badge that fetches and renders
the summary. Pass your own client (preferred — reuses its auth) or let the
badge build a narrow internal one from env / baseUrl:
role="status"), empty (no pid),
error (role="alert" with retry — 404 maps to “Unknown user”), and success
(Lv 5 · 1,701 XP plus an optional role="progressbar" with value
attributes). The layout is fluid (no fixed widths) for desktop and mobile.
Report XP events (server-to-server)
Availability. The HTTP event-intake route is M3-gated and not yet shipped. The contract below is what the platform already enforces in code (permission model, event catalog, idempotency, quotas) and what intake will enforce at the HTTP boundary. Until intake ships, awards happen through the admin API (adjustments / reversals) and the dashboard. Do not build a client-side award path — there will not be one.Event reporting is server-to-server only. Your backend holds the app secret, exchanges it for a short-lived machine token (
POST /v1/auth/token — see Apps & workspace), and
reports events with that token. Browser code never reports events.
Idempotency
Every report carries anevent_id chosen by your backend. Deduplication is
per app: the completion key is the composite (appId, eventId), so two
apps may independently reuse the same event_id — only the same app
reporting the same event_id twice is a duplicate, and the retry writes
nothing. An event_id always belongs to a task (eventId requires
taskId); one-time achievements additionally scope on resource_id.
Rules for event_id: 1–128 chars, stable per real-world occurrence
(e.g. order_9f2…_delivered), never reused across distinct occurrences.
On retry-after-timeout, resend the same event_id — the duplicate is
acknowledged without a second award.
Permission errors
Intake fails closed. A report is rejected unless the app is active, Verified, explicitly XP-enabled, and the event type is on the app’s admin-approved list. Losing Verified (or XP permission) disables submissions immediately. Reads are unaffected.
Approved event types come from the MVP catalog only:
onboarding_completed, identity_verified, campaign_completed,
achievement_earned, ecosystem_activity (lowercase letters, digits, and
underscores, 3–64 chars). Unknown types are refused and nothing is persisted.
Quotas
Admins set per-app daily / weekly / monthly XP caps plus org-level caps, and can suspend an app’s awards (reason required). Over-cap and suspended reports are rejected with the accounting state (sum + delta > cap) — no partial
awards, no ledger write.
Error envelopes
Same shape as every PeridotID error:message is a string, or an array of strings when validation fails. The codes
relevant here: 400 (bad pid format, invalid event/task payload),
401 (missing/expired credential), 403 (forbidden — e.g. unverified or
XP-disabled app on intake, admin-only route), 404 (unknown/deleted user,
unknown task), 429 (rate limit — back off and retry; see below).
Rate limits
Every route is rate-limited; the global default is 100 requests/minute per client. Level reads inherit that default. Auth and token endpoints carry stricter per-route limits (see Errors). Integrators polling levels (e.g. a leaderboard refresh) should cache perpid and stay well under the
default — re-read on user action, not on a timer.
Monitoring (operators)
The dashboard’s Leveling → Overview page aggregates the pipeline health: total XP distributed, users with XP state, the level distribution, recent ledger activity, and unreviewed suspicious signals. The underlying data:- Award / error rates:
xp_ledger_entries(kind,appId,createdAt;task_awardrows are awards). Rejected intake attempts leave no ledger row — thexp_suspicious_activitiesfeed is the rejection-visibility proxy until intake ships structured reason codes. - Level-distribution drift:
user_xp_statesgrouped bylevel(one level holding nearly all users points at a curve/policy-config problem). - Quota breaches: per-app ledger sums vs
xp_quotascaps — any award over adailyCapis a cap-enforcement failure, page immediately. - Reconciliation invariant:
sum(xp_ledger_entries.deltaXp)must equalsum(user_xp_states.totalXp)— drift means corruption or a bad migration.
deploy/alerts/
(xp-leveling-check.sh on cron, queries in xp-leveling-detected.sql,
triage in xp-leveling-runbook.md).
Intake kill-switch
Any admin can stop awards without touching the ledger — no deploy, no SSH, no DB writes: suspend the offending app (suspended=true, reason required),
deactivate the task, or revoke XP permission (xpEnabled=false). New award
attempts then fail closed with a permission error and no ledger write;
existing totals, levels, history, and reads are unaffected. The ledger is
append-only (reversals are new negative entries — there is no update or
delete path), so repair is re-level/backfill, never hand-editing. Confirm
the flip in Leveling → XP History (no new task_award rows) and re-enable
by unsuspending / reactivating when the incident clears.