Developer documentation

Build on the identity rail.

An API-first identity utility for community building: per-community OIDC/SSO with NFC-verified membership, pairwise-private subjects, consent-first data sharing, and a community-owned social graph — every utility forwarded through the API so you can build sign-on widgets and community tools on top.

A DogTag — UV-printed Ti Project art, Berify NFC inside

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.

ProviderTypeCredentialsState
Pernox accountEmail + password + optional TOTPNone — always availableBuilt
XRPL walletSign a one-time challengeNone — self-containedBuilt
GoogleOAuth 2.0GOOGLE_CLIENT_ID/SECRETBuilt
RedditOAuth 2.0REDDIT_CLIENT_ID/SECRETBuilt
Twitter / XOAuth 2.0 + PKCETWITTER_CLIENT_ID/SECRETBuilt
GitHubOAuth 2.0GITHUB_CLIENT_ID/SECRETBuilt

OIDC endpoints

Authorization-code + PKCE (S256) only. Public clients. Served on each community's issuer host.

EndpointMethodPurpose
/.well-known/openid-configurationGETDiscovery
/authorizeGETAuth code + PKCE; consent-gated
/tokenPOSTCode + rotating refresh (reuse-detection)
/userinfoGET/POSTPairwise sub + consent-gated claims
/revokePOSTRFC 7009 revocation
/logoutGET/POSTEnd-session (CSRF-safe)
/jwks.json · /jwks/{kid}GETPublic 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.

SurfaceMethodAuthWhat
/t/{uid}GETPublic tapTap ingress → neutral outcome page
/api/tags/bindPOSTTenant keyBind a tag to a member (Berify-verified)
/api/tags/revoke · reissuePOSTTenant keyKill / rotate a binding
/api/tags/repointPOSTTenant keyReassign; cross-tenant needs receiver consent
/api/tags/repoint-consentPOSTTenant 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_unavailable

The 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.

EndpointMethodReturns
/api/graphGETgraph: consent-filtered { nodes: [{ ref, handle, degree }], edges: [{ a, b, weight }], hidden_private_count, truncated }
/api/graph/statsGETstats: { members, connections, density, mau, tag_activity, period }
/api/graph/connectPOSTTap → 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=GETego: 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=GETmutuals: visible mutual refs + count for an explicit pair; either endpoint private → empty
/api/graph/activityGETactivity: ANONYMOUS tier only — { day, source, count } rows; the named tier never crosses the API
/api/graph/meGETMEMBER 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/connect

Also: 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.

EndpointAuthWhat
/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/eligibilityTenant keyProduct-gate check for a Shopify store (the store applies its own discount/metafield)
/api/providers/stripe/checkoutTenant keyGated Stripe Checkout — body below; refuses 409 until the community's payouts are ready
/api/providers/stripe/webhookStripe signatureSettlement ingest: sales, refunds (payout-reversing), payout readiness
/api/providers/shopify/webhookShopify 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/brandingPublic (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.

SurfaceAuthWhat
/sponsorSigned-in personApply to a community; it decides under its own by-laws (approval = vendor member)
/api/offers/redeemMember 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/mineMember 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.

WidgetWhat it does
/widget/joinBranded 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/sponsorBranded "Sponsor this community" door → /sponsor.
/widget/dropHonest 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/badgePoints/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/pulseCommunity 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/scheduleUpcoming 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.

EndpointMethodWhat
/api/interestPOST (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/confirmGET?t=<emailed token> → flips the reserve to confirmed (single use), lands on the campaign page.

Plans

PlanMembersPriceNotes
Freeup to 50$0All sign-in providers, 1 community
Proup to 1,000$20/moUp to 1,000 members — the one enforced difference (clubs & API keys are on every tier)
Enterprise · self-hostedunlimitedfrom $15k licenseBring your own database; billed by contract
White-labelunlimitedfrom $250k + supportYour 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

CapabilityState
OIDC issuer per community (code+PKCE, EdDSA, pairwise)Built
Six sign-in providersBuilt
Consent-gated social scope, live-revocableBuilt
DogTag tap ingress + tag lifecycle APIBuilt
Club-owned social graph + utility APIsBuilt
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, receiptsBuilt
Shopify eligibility (product gates; store applies its own discount)Built
Interest matching (declared interests → member suggestions)Built
Discovery scope / cross-club opt-in offersHorizon
Enable SSO for your communityGet an API key in the console →