Community
Each community is its own OIDC issuer, resolved by request host. Members sign in to its sites; the platform stays invisible to them. Signing keys, plans, providers, and branding are all per-community.
Pairwise
Every relying party sees a different stable sub for the same person (dedicated immutable secret). No RP — and not the platform — can correlate a member across sites.
Consent
Scopes: openid · profile · email · social · offline_access · eligibility · offers. Every sensitive scope is default-deny per client, revocable live, and re-checked on every use — social shares handle + memberships, eligibility allows leak-free yes/no standing checks, offers lets a site redeem community offers for the member. The member does the linking, never the platform.
Membership
Members belong to a community and optionally its clubs ("club" here always means a group inside a community — a team, a chapter — never the community itself). Membership rolls up; each carries a MEMBER-set visibility (clients / private — the knob lives on the member's own account page; private removes them from the community's graph, directory, and RP claims while still counting anonymously). Nothing is stored plaintext — emails encrypted + blind-indexed, passwords scrypt, secrets sealed.
Sign-in providers
A community chooses which it offers; the login page renders each by its real state — never a dead button. Provider policy is enforced at the route, not just the UI.
| Provider | Type | Credentials | State |
|---|---|---|---|
| Pernox account | Email + password + optional TOTP | None — always available | Built |
| XRPL wallet | Sign a one-time challenge | None — self-contained | Built |
| OAuth 2.0 | GOOGLE_CLIENT_ID/SECRET | Built | |
| OAuth 2.0 | REDDIT_CLIENT_ID/SECRET | Built | |
| Twitter / X | OAuth 2.0 + PKCE | TWITTER_CLIENT_ID/SECRET | Built |
| GitHub | OAuth 2.0 | GITHUB_CLIENT_ID/SECRET | Built |
OIDC endpoints
Authorization-code + PKCE (S256) only. Public clients. Served on each community's issuer host.
| Endpoint | Method | Purpose |
|---|---|---|
/.well-known/openid-configuration | GET | Discovery |
/authorize | GET | Auth code + PKCE; consent-gated |
/token | POST | Code + rotating refresh (reuse-detection) |
/userinfo | GET/POST | Pairwise sub + consent-gated claims |
/revoke | POST | RFC 7009 revocation |
/logout | GET/POST | End-session (CSRF-safe) |
/jwks.json · /jwks/{kid} | GET | Public keys, per-community |
DogTag & tags
Berify-verified NFC DogTags. Every tap is verified server-side; every binding carries an epoch, so a lost tag dies in seconds while the identity survives. Tap verification fails closed.
| Surface | Method | Auth | What |
|---|---|---|---|
/t/{uid} | GET | Public tap | Tap ingress → neutral outcome page |
/api/tags/bind | POST | Tenant key | Bind a tag to a member (Berify-verified) |
/api/tags/revoke · reissue | POST | Tenant key | Kill / rotate a binding |
/api/tags/repoint | POST | Tenant key | Reassign; cross-tenant needs receiver consent |
/api/tags/repoint-consent | POST | Tenant key (the RECEIVING community) | Mint the one-time 10-minute consent token a cross-tenant repoint requires |
Tags API — request / response shapes (build against these)
# All: Authorization: Bearer <tenant API key> · Content-Type: application/json
# Member fields (user_id / new_user_id) take a pgm_ member ref (what the
# graph API serves) — raw account uuids are accepted as a legacy courtesy only.
POST /api/tags/bind { "tag_ref": "…", "user_id": "pgm_…" }
# 200 { "ok": true, "epoch": 1, "binding_id": "…" }
POST /api/tags/revoke { "tag_ref": "…", "reason": "lost" }
# reason: lost | stolen | left | repointed | scheduled_rotation
# 200 { "ok": true, "revoked_epoch": 1 }
POST /api/tags/reissue { "tag_ref": "…" }
# 200 { "ok": true, "old_epoch": 1, "new_epoch": 2 }
POST /api/tags/repoint { "tag_ref": "…", "new_user_id": "pgm_…",
"to_tenant_slug": "other-community", # optional
"consent_token": "…" } # cross-tenant only
# 200 { "ok": true, "old_epoch": 1, "new_epoch": 2 }
# 403 { "error": "consent_required" } cross-tenant without a consent token
# (a cross-tenant new_user_id ref is the TARGET community's ref — ask them for it)
POST /api/tags/repoint-consent # authenticated as the RECEIVING community
{ "tag_ref": "…", "new_user_id": "pgm_…" }
# 200 { "ok": true, "consent_token": "…", "expires_in": 600 }
# Errors, all routes: 400 invalid_request (missing/malformed field) ·
# 400 invalid_json · 401 invalid_api_key · 404 not_found (tag/tenant/user —
# cross-tenant lookups answer not_found, no existence leak) · 409 conflict
# (already_bound / not_bound / …) · 503 berify_unavailableThe social graph — the community owns it
Connections form when members tap each other's DogTags, a member who goes private disappears from the graph, and Pernox sees only aggregate counts — never who is connected to whom.
Member refs are per-community. Every member-identifying value on this API is a pairwise ref (pgm_…) — the same member has a different ref in every community, and raw account ids never appear. Two communities cannot correlate members or intersect edges by comparing API responses. Refs are stable within a community, work as inputs everywhere a member is named (including the tags API), and resolve only under your own tenant key. Handles remain the one opt-in cross-site name.
The member's own floor lives on their account page, no API needed: their connections (a private peer collapses to a count, never a name), the visibility knob, and their declared interests — declared-only, never inferred, and gateable via the hasInterest leaf (topic ids are listed on your console's Gates page).
Wire shape: every response is wrapped { ok: true, <key>: … } (the key named per row below) and all fields are snake_case.
| Endpoint | Method | Returns |
|---|---|---|
/api/graph | GET | graph: consent-filtered { nodes: [{ ref, handle, degree }], edges: [{ a, b, weight }], hidden_private_count, truncated } |
/api/graph/stats | GET | stats: { members, connections, density, mau, tag_activity, period } |
/api/graph/connect | POST | Tap → connection. Body { actor_user_id, tag_ref } (actor_user_id = a pgm_ ref) → connection: { a, b, tap_count } (a/b are refs) |
/api/graph/ego?user_id= | GET | ego: a member's own view { connections: [{ peer_ref, handle, tap_count, last_at, source }], hidden_count, degree } — a private member answers as the same empty shape as an unknown ref |
/api/graph/mutuals?a=&b= | GET | mutuals: visible mutual refs + count for an explicit pair; either endpoint private → empty |
/api/graph/activity | GET | activity: ANONYMOUS tier only — { day, source, count } rows; the named tier never crosses the API |
/api/graph/me | GET | MEMBER token (live social consent), not a tenant key — and no ok wrapper: the token holder's own view { degree, hidden_count, connections: [{ handle, tap_count, last_at, source }] }. PAIRWISE: never a peer id of any kind — handles only, private peers fold into hidden_count unnamed. Missing consent answers as the same empty shape as knowing nobody. |
A missing or malformed parameter is 400 invalid_request; unknown refs answer with the same empty shapes as private members (no probe oracle). Connection suggestions are deliberately NOT on this API: 2-hop structure about a member belongs to the member, so "people you may know" renders only on the member's own account page.
Utility APIs — your data plane
Backend-to-backend, authenticated with your tenant API key (issued in your console). Same convention across graph, orgs, and tags. Rate-limited per key.
# the community's connection graph — member refs are per-community pgm_ values
curl -H "authorization: Bearer $PGX_KEY" https://<issuer>/api/graph
# → { "ok": true, "graph": { "nodes":[{"ref":"pgm_…","handle":"ava","degree":3}],
# "edges":[{"a":"pgm_…","b":"pgm_…","weight":2}],
# "hidden_private_count":1, "truncated":false } }
# form a connection from a DogTag tap
curl -X POST -H "authorization: Bearer $PGX_KEY" -H "content-type: application/json" \
-d '{ "actor_user_id":"pgm_…", "tag_ref":"<tapped-tag>" }' \
https://<issuer>/api/graph/connectAlso: GET/POST /api/orgs (list your clubs + effective providers / create one: { slug, name, login_providers? }), GET/PATCH /api/orgs/{slug} (club detail + its next scheduled events, source + sync time labeled — no member data / update { name?, login_providers? }), and POST /api/orgs/{slug}/clients ({ name, redirect_uris } — register an org-bound SSO client; POST only, there is no client listing here). All wrapped { ok: true, … }.
Eligibility rail & commerce
The fundraising primitive: a community authors gates (membership, tags, ranks, connections, club subtrees) and an external store learns only { eligible } — plus, on yes, a 5-minute EdDSA entitlement it verifies offline against the community JWKS. Never attributes, standing values, or the reason.
| Endpoint | Auth | What |
|---|---|---|
/api/gate/check?gate=&presenter=&org= | Member token (eligibility) | Yes/no + signed entitlement; presenter=<did> binds it to a key-holder (cnf.did) — how Echo gates rooms; org=<club-slug> evaluates the gate at that club (an org-scoped gate overrides the community-wide one; an unknown club answers as ineligible) |
/api/providers/shopify/eligibility | Tenant key | Product-gate check for a Shopify store (the store applies its own discount/metafield) |
/api/providers/stripe/checkout | Tenant key | Gated Stripe Checkout — body below; refuses 409 until the community's payouts are ready |
/api/providers/stripe/webhook | Stripe signature | Settlement ingest: sales, refunds (payout-reversing), payout readiness |
/api/providers/shopify/webhook | Shopify HMAC (per-store secret, or the app secret) | orders/paid ingest → the sale lands on the community ledger attributed to its storefront (resolved via X-Shopify-Shop-Domain); link it to the rail with a pernox_entitlement_jti note attribute. Fail-closed; test orders ignored; re-deliveries no-op; refunds/create is ingested too — negative rows net against the order at the sale-time fee ratio (per-refund idempotent, capped at the sale, no payout leg) |
/api/branding | Public (CORS *) | Per-community theming for RPs: name, tagline, accent, issuer + the community's public shop links |
Shopify eligibility — request / response (JSON-first, for store developers)
POST /api/providers/shopify/eligibility
Authorization: Bearer <tenant API key> # issued in your community console
Content-Type: application/json
{ "member_token": "<the member's Pernox access token from your SSO login>",
"product_gid": "gid://shopify/Product/123456" }
# 404 { "error": "product_not_gated" } product has no gate mapping
# 400 { "error": "member_token and product_gid required" }
# 401 / 429 bad tenant API key / rate limited
# 200 { "eligible": false } not eligible / missing eligibility
# consent / token from another community
# 200 { "eligible": true,
# "gate": "gold-drop",
# "entitlement": "<5-min EdDSA JWT — verify offline against the
# community JWKS, then apply your discount/metafield>" }Gated Stripe Checkout — request / response
POST /api/providers/stripe/checkout
Authorization: Bearer <tenant API key>
Content-Type: application/json
{ "member_token": "<the member's Pernox access token from your SSO login>",
"gate": "gold-drop",
"amount_cents": 2500, # integer > 0, usd
"product_name": "Gold drop tee",
"success_url": "https://your.site/thanks",
"cancel_url": "https://your.site/drop",
"org_id": "<club id>", # optional: evaluate at a club
"product_gid": "gid://shopify/Product/…" }# optional
# 400 { "error": "member_token, gate, product_name, amount_cents (>0), success_url, cancel_url required" }
# 401 / 429 bad tenant API key / rate limited
# 409 { "error": "payouts_not_ready" } finish Stripe payout onboarding first
# 503 { "error": "stripe_not_configured" }
# 200 { "eligible": false } not eligible / missing eligibility consent
# 200 { "eligible": true, "checkout_url": "<hosted Stripe Checkout — redirect the buyer>" }Settlement: sales record PII-free on the community's ledger; its share auto-transfers via Stripe Connect; refunds reverse the transferred share at the sale-time rate.
Sponsorships & offers — prepaid access, not ads
Sponsors fund pools up front (settling like a sale: the community keeps 65% rising to 90% as lifetime volume grows; the platform's take credits the community's subscription). Members claim shareable references and pass them person-to-person — nothing is broadcast, every reach number is a community-set cap, and no cash ever flows to an individual: redemptions grant points into standing and a signed receipt the community's own storefronts honor as the discount.
| Surface | Auth | What |
|---|---|---|
/sponsor | Signed-in person | Apply to a community; it decides under its own by-laws (approval = vendor member) |
/api/offers/redeem | Member token (offers) | Redeem a shared code → points + a 15-minute EdDSA receipt (aud pgx-offer-surface, verified against the community JWKS; names the redemption, never the member) |
/api/offers/mine | Member token (offers) | The token holder's own read: { points, pools: [{ label, share_text, discount_bps, held, max_per_member }], references: [{ uses, max_uses }] } — never a code (only hashes exist), never another member's anything. Missing consent answers as the same empty shape as having nothing. |
Embeds — widgets for your own site
Small tenant-branded iframes your community's external sites can embed. The allowlist IS your client registry: a host origin may frame these only when it is registered as a redirect origin on one of your SSO clients (console → Developers). Widgets set no cookies, hold no session, and never persist a member token.
| Widget | What it does |
|---|---|
/widget/join | Branded join button — click is a TOP-LEVEL navigation into sign-in (never auth inside the frame). data-login points it at your own OIDC start URL (must be a registered origin). |
/widget/sponsor | Branded "Sponsor this community" door → /sponsor. |
/widget/drop | Honest three-state gate badge: unknown until YOUR page hands it the member's token (window.Pernox.setMemberToken(…) — the token your backend already holds as an OIDC RP), then a same-origin gate check flips unlocked / locked. data-org="<club-slug>" scopes the check to a club (the loader forwards it as gate/check?org=). Errors stay unknown — never faked. |
/widget/badge | Points/connections badge: unknown until your page hands it the member's token (same setMemberToken handshake), then same-origin reads of /api/offers/mine + /api/graph/me render N pts · M connections (private peers counted, never named). Errors stay unknown. |
/widget/pulse | Community pulse — UNauthenticated, counts only (members · connections · taps in 30 days) from the anonymous audit-spine tier. Structurally id-free: no script, no member data in the document. |
/widget/schedule | Upcoming club events (next 8) from the league schedule rail — unauthenticated, no member data, source + sync time labeled. |
<script src="https://<community-host>/widget/v1/pernox.js"
data-widget="drop" data-gate="<gate-slug>" data-org="<club-slug>"></script>
<!-- data-org (optional) scopes the gate check to one club inside the community -->
<!-- events: "pernox:ready", "pernox:state" (detail: locked|unlocked|unknown) -->Host-side cost, honestly: a strict host CSP needs frame-src for the community host; the loader script additionally needs script-src for it. A bare <iframe> paste works with no script at all.
Campaign interest — pre-launch reserves
The reserve rail behind campaign showcase pages (first: Wear the League). An interest list, not an order book — no payment fields exist, emails are encrypted + blind-indexed, and a row counts as demand only after its single-use emailed token is clicked. Success responses are deliberately identical for new, repeated, and already-confirmed addresses.
| Endpoint | Method | What |
|---|---|---|
/api/interest | POST (form) | campaign · email · product×N · team_pick → 303 back to the campaign page (reserved=1, or recorded on deployments without an email provider). Per-IP and per-address throttled. |
/api/interest/confirm | GET | ?t=<emailed token> → flips the reserve to confirmed (single use), lands on the campaign page. |
Plans
| Plan | Members | Price | Notes |
|---|---|---|---|
| Free | up to 50 | $0 | All sign-in providers, 1 community |
| Pro | up to 1,000 | $20/mo | Up to 1,000 members — the one enforced difference (clubs & API keys are on every tier) |
| Enterprise · self-hosted | unlimited | from $15k license | Bring your own database; billed by contract |
| White-label | unlimited | from $250k + support | Your brand end-to-end; billed by contract |
The member cap counts distinct members (rolling up clubs). Invoices snapshot the plan held during the period, and a community's sponsorship take credits its metered plan fee (floored at $0).
Status
| Capability | State |
|---|---|
| OIDC issuer per community (code+PKCE, EdDSA, pairwise) | Built |
| Six sign-in providers | Built |
| Consent-gated social scope, live-revocable | Built |
| DogTag tap ingress + tag lifecycle API | Built |
| Club-owned social graph + utility APIs | Built |
| Plans, member-cap enforcement, invoices (+ sponsorship offset) | Built |
| Gates + offline-verifiable entitlements (incl. holder binding) | Built |
| Settlement rail + Stripe Connect payouts (mock-tested; live keys pending) | Built |
| Sponsorships: pools, applications, shared references, receipts | Built |
| Shopify eligibility (product gates; store applies its own discount) | Built |
| Interest matching (declared interests → member suggestions) | Built |
| Discovery scope / cross-club opt-in offers | Horizon |

