@orangecheck/me-client · SDK reference
Full reference: ochk.io/docs/sdk/me-client, generated from the TypeScript source on every release. This page is the curated overview; the generated reference is the source of truth for every export, type and signature.
MIT. One package for signing people in with orangecheck and, if you choose, paying them. Three entry points:
@orangecheck/me-client: theoc.*namespaces (event, webhook, config, scope, session, payment, identity) and every type. No React, so a Node backend imports it as it is.@orangecheck/me-client/server: checks the session on your server.withOcAuth(Next.js Pages),ocAuthExpress,ocAuthHono,getOcSession(headers),verifyOcToken(token). You never fetch or handle a JWK: the SDK does that and caches it.@orangecheck/me-client/popup: browser only.signInWithOc()opens the orangecheck sign-in popup and resolves with a token bound to your origin, the way Google's and GitHub's popups do.
install
yarn add @orangecheck/me-client
That is all sign-in needs. @orangecheck/auth-client is only for a React app,
once your server keeps the session in its own cookie: see
React below.
What changed in 0.32
OcSignInButtonis gone. It was the only React code in the main entry, so a Node backend that importedoccrashed looking forreact. Sign in withsignInWithOc()from/popup.react,react-domand@orangecheck/auth-clientare no longer peer dependencies.- A
projectKeythat names no project answers500 unknown_project_key, and logs which key, once. It used to read as an outage (503 bond_policy_unavailable) on every request. - Typed sessions.
req.ocSessionis typed on Express's ownRequest, andnew Hono<OcHonoEnv>()typesc.get('ocSession'). No casts. oc.webhook.verifytakes a missing header (asreq.header()returns it) and answersok: false.
The first published 0.32 is 0.32.1.
sign-in in the browser
import { signInWithOc } from '@orangecheck/me-client/popup';
button.addEventListener('click', async () => {
const result = await signInWithOc();
if (!result) return; // closed, blocked, or aborted
const r = await fetch('/api/auth/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: result.token }),
});
if (r.ok) location.assign('/dashboard');
});
Call signInWithOc() inside a user gesture: browsers block window.open
anywhere else. It resolves to { account, token }, or null when the person
closes the popup, the browser blocks it, or your signal aborts. Trust only the
token; account is unsigned.
the session cookie
Post the token to a route of your own that verifies it and keeps it in an httpOnly cookie. Every route after that reads the cookie, so no script keeps the token and nothing sends a Bearer header.
// Next.js Pages · pages/api/auth/session.ts
import type { NextApiRequest, NextApiResponse } from 'next';
import { verifyOcToken } from '@orangecheck/me-client/server';
export default async function session(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== 'POST') return res.status(405).end();
const token = String(req.body?.token ?? '');
const s = await verifyOcToken(token, {
audience: 'https://yourapp.example',
});
if (!s) return res.status(401).json({ ok: false });
const maxAge = (s.exp ?? 0) - Math.floor(Date.now() / 1000);
res.setHeader(
'Set-Cookie',
`oc_session=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${maxAge}`
);
res.status(200).json({ ok: true });
}
checking the session on your server
withOcAuth for Next.js Pages Router:
// pages/api/auth/me.ts
import { withOcAuth } from '@orangecheck/me-client/server';
export default withOcAuth(
async (req, res) => {
if (!req.ocSession) return res.status(401).json({ ok: false });
res.status(200).json({
ok: true,
account: {
id: req.ocSession.sub,
did_oc: req.ocSession.did_oc,
display_name: req.ocSession.name ?? null,
nostr_npub: req.ocSession.npub ?? null,
},
});
},
{
audience: 'https://yourapp.example',
projectKey: process.env.OC_PROJECT_KEY!,
}
);
ocAuthExpress for Express:
import { ocAuthExpress } from '@orangecheck/me-client/server';
import express from 'express';
const app = express();
app.use(
ocAuthExpress({
audience: 'https://yourapp.example',
projectKey: process.env.OC_PROJECT_KEY!,
})
);
app.get('/api/profile', (req, res) => {
if (!req.ocSession) return res.status(401).json({ error: 'sign in' });
res.json({ did_oc: req.ocSession.did_oc });
});
ocAuthHono for Hono, Bun and Deno:
import type { OcHonoEnv } from '@orangecheck/me-client/server';
import { ocAuthHono } from '@orangecheck/me-client/server';
import { Hono } from 'hono';
const app = new Hono<OcHonoEnv>();
app.use(
'*',
ocAuthHono({
audience: 'https://yourapp.example',
projectKey: process.env.OC_PROJECT_KEY!,
})
);
app.get('/api/profile', (c) => {
const session = c.get('ocSession');
if (!session) return c.json({ ok: false }, 401);
return c.json({ did_oc: session.did_oc });
});
getOcSession(headers) is the framework-agnostic primitive. It takes a Web
Headers object or a plain { cookie, authorization } object, reads the
oc_session cookie (or, failing that, a Bearer token), and returns the verified
SessionPayload or null.
The wrappers all take one options bag:
| Option | What it does |
|---|---|
audience | Your own origin. A token issued to any other site is refused. Leave it out only on a *.ochk.io site that reads the family cookie. |
projectKey | Enforces who can sign in as set on your project: everyone, or only accounts backed by bitcoin. A key that names no project gets 500 unknown_project_key. |
required | true answers 401 before your handler runs when nobody is signed in. |
requireBond | { minSats, minDays, maxAgeSecs } in code. Wins over projectKey. |
React, with auth-client
Only for a React app whose server keeps the session in its own cookie, as above.
<OcSessionProvider> from @orangecheck/auth-client reads your own
/api/auth/me, so answer it with an account id and its did_oc, as the
withOcAuth sample does.
yarn add @orangecheck/auth-client
import type { ReactNode } from 'react';
import { OcSessionProvider, useOcSession } from '@orangecheck/auth-client';
export function Providers({ children }: { children: ReactNode }) {
// Your own cookie, one account: no tab pinning against ochk.io.
return (
<OcSessionProvider config={{ multiAccount: false }}>
{children}
</OcSessionProvider>
);
}
export function Header({ signIn }: { signIn: () => void }) {
const { status, account } = useOcSession();
if (status === 'loading') return null;
if (!account) return <button onClick={signIn}>Sign in</button>; // signInWithOc(), as above
return <span>signed in as {account.didOc.slice(0, 16)}…</span>;
}
useOcSession() returns { status, account, refresh, error, … }, with status
loading | authenticated | anonymous | error. useOptionalOcSession() returns
null instead of throwing when no provider is mounted. Sign people out with
your own route that clears the cookie, then call refresh().
@orangecheck/me-client does not re-export any of this: import OcAccount,
OcSessionState, OcSessionStatus and OcAuthConfig from
@orangecheck/auth-client.
oc.event.fire · send an event
How your app pays its users, one event at a time: account_creation,
session_creation, stamp_signing, payment_authorization, or a key of your
own. Each event is One-time, Per action or Per session, as the catalog
(EVENT_SUBTYPES) says; what your user earns comes from your price config via
computeFees(). The receipt is recorded under your project_key and shows on
your project's Receipts tab at ochk.app.
Call it from your backend. signingSecret signs the request as your project
(the SDK signs the exact bytes it sends), and accountId names who earns: the
person's account id (did_oc), stored beside your own user row. Before
me-client 0.30 this option was called userAddress; that name still works. No
user session token is forwarded.
Paying your users is optional. An event earns only inside your project's
pay periods (always, in promos, or never); outside them the
call succeeds with earned: false and nothing is recorded or charged.
// server-side only · the secret never reaches a browser
const stamp = await oc.event.fire({
project_key: process.env.OC_PROJECT_KEY!,
subtype: 'stamp_signing',
action_label: 'review · march invoice',
signingSecret: process.env.OC_SIGNING_SECRET,
accountId: user.did_oc,
});
if (stamp.earned) {
// stamp.event.id, .gross_fee_sats, .user_earned_sats, .verify_url
} else {
// stamp.reason === 'not_paying'; stamp.next_pay_period, or null
}
// An event priced as a share of a payment carries the payment amount:
const paid = await oc.event.fire({
project_key: process.env.OC_PROJECT_KEY!,
subtype: 'payment_authorization',
payment_amount_sats: 50_000,
signingSecret: process.env.OC_SIGNING_SECRET,
accountId: user.did_oc,
});
| Function | Signature | Note |
|---|---|---|
oc.event.fire(opts) | (FireEventOptions) => Promise<FireEventResult> | { earned: true, event } with the recorded event, or { earned: false, reason: 'not_paying', next_pay_period }. |
oc.config.validate · check a price config locally
Check your config against the same rules the server enforces, with no network
call. You edit a project's prices on its Paying tab at
ochk.app; the SDK has no oc.config.update(),
because configs belong to projects, not accounts.
For each event you set what your user earns. You pay that ÷ 0.8, the 20% fee included: earns 100, you pay 125. The smallest earning is 1 sat, and you pay 2.
import type { IntegratorPriceConfig } from '@orangecheck/me-client';
import { oc } from '@orangecheck/me-client';
declare const cfg: IntegratorPriceConfig; // your project's config
const result = oc.config.validate(cfg);
if (!result.ok) console.error(result.errors);
| Function | Signature | Note |
|---|---|---|
oc.config.validate(cfg) | (IntegratorPriceConfig) => ValidationResult | No network call. |
The same function is also exported as the top-level validateIntegratorConfig.
oc.webhook.verify · Ed25519 signature verification
Always pass the raw body bytes, not the parsed JSON. Frameworks that re-serialize before your handler produce a different byte sequence and the signature will not validate.
const result = await oc.webhook.verify(
rawBody, // string | Uint8Array
req.header('OC-Signature'),
req.header('OC-Key-Id')
);
if (!result.ok) return res.status(401).end(result.reason);
A missing header answers ok: false, so pass req.header(...) straight in. The
key set at ochk.io/.well-known/jwks.json is fetched and cached for an hour
when you pass no jwk. Stale-on-error: a brief outage of that key set uses the
cached copy rather than rejecting every webhook. Webhooks has a
full receiver.
Agent delegation (
oc.delegation.*) is a separate primitive and lives on agent.ochk.io / ochk.io/docs/agent, not on orangecheck. me-client doesn't ship a delegation namespace; reach for@orangecheck/agent-signerto issue or revoke agent delegations from your integration, and@orangecheck/agent-coreto verify them.
oc.scope.* · what people share with your app
Signing in tells your app the account's sub and did_oc, and nothing about
its email or Bitcoin address. Both ids are the same at every site, so sites that
compare notes can match a user. Scope reads below carry a per-app subject
instead. To read more (an email, a Bitcoin address, a display name) you ask, and
the person decides.
Full design at PRIVACY-ARCHITECTURE.md.
oc.scope.granted
Read what the signed-in person shares with your project now. Returns the per-app
sub plus the values they share.
import { oc } from '@orangecheck/me-client';
const { sub, scopes_granted, scopes } = await oc.scope.granted({
project_key: 'pk_live_yourapp',
});
// `sub` here is per-app: different at every project. It is NOT the
// token's `sub` (req.ocSession.sub), which is the same at every site.
// Event payloads carry this scoped `sub` too, so don't join the two.
console.log(sub); // "sub_3K7xQ2mN1pYwB8tRvE…"
// `scopes` holds only what the person shares.
console.log(scopes.email); // "user@example.com" · or undefined
oc.scope.request
Send the person to orangecheck's page that asks them. After they decide, the
browser returns to return_to, and later oc.scope.granted() calls reflect
what they chose.
oc.scope.request(['email', 'display_name'], {
project_key: 'pk_live_yourapp',
return_to: window.location.href,
});
// ↑ this navigates · the line after never runs
For each item the person picks Share for an hour, Share, or Don't share. They can stop sharing any time at ochk.app/me/apps.
Valid scopes
| Scope | Reveals | Use case |
|---|---|---|
bitcoin_address | Master did:bip322:bc1q… (BIP-322 users only) | On-chain holdings verification, payment-channel co-signing |
email | Email address (email-OTP users only) | Transactional email · receipts, password reset |
attest_tier | Deprecated: anonymous / bonded | None; see below |
display_name | A user-chosen handle (separate from real identity) | Forum / social integrations where some name is needed |
Don't gate on attest_tier to keep bots out. It is deprecated, and bonded
only ever meant one signature over an address, with no amount behind it. To let
in only accounts backed by bitcoin, read the token's oc_bond claim: see
Who can sign in.
Migration from user_address (≤ 0.9.0)
Pre-0.10.0 webhook payloads delivered the user's master address as
user_address. From 0.10.0 onward, payloads use the per-app sub (the same
scoped id oc.scope.granted() returns, not the token's sub) plus optional
scopes (shared fields). To migrate:
- Update SDK to
^0.10.0. - Switch from
event.user_address→event.subfor user records in your database. - If you actually need the user's email or address, call
oc.scope.request(['email'])and readevent.scopes.email/oc.scope.granted().scopes.email.
The user_address field is retired from outgoing payloads in the matching
me.ochk.io deploy. Existing rows in your database keyed on old user_address
values stay valid; just expect new events to use sub.
| Function | Signature | Note |
|---|---|---|
oc.scope.granted(opts) | (GrantedOptions) => Promise<GrantedResult> | What the signed-in person shares with your project now. |
oc.scope.request(scopes, opts) | (Scope[], RequestOptions) => never | Navigates to the page that asks · throws after. |
oc.session.* · sessions (Per session)
Apps that pay per session pay for the session_creation event, which your
server sends with oc.event.fire (above) once per real session. create()
opens a session and records no charge; refresh() and invalidate() are free,
telemetry only.
import { oc } from '@orangecheck/me-client';
const session = await oc.session.create({
scope: ['identity', 'payment'],
sessionPolicy: { duration_seconds: 7 * 86400, refresh: 'sliding' },
});
await oc.session.refresh(session.id);
await oc.session.invalidate(session.id);
| Function | Signature | Note |
|---|---|---|
oc.session.create(opts) | (SignInOptions) => Promise<Session> | No charge. |
oc.session.refresh(id) | (string) => Promise<Session> | Free. |
oc.session.invalidate(id) | (string) => Promise<void> | Free. |
oc.payment.authorize · a payment (Per action)
const result = await oc.payment.authorize({
identity: account.did_oc, // the signed-in account's id
amount_sats: 240_000,
description: 'march invoice',
});
// result.id, result.user_envelope_id, result.verify_url
The signed-in session decides whose payment it is: me.ochk.io uses that
account's did_oc and ignores any other identity. Today the call records the
authorization and moves no sats, because paying is in testing.
Other signals
Who can sign in is the signal to reach for first: an account backed by a coin, one account per coin at your app. These are others.
oc.identity.verifyActivityAttestation · activity across apps
Verify a signed activity attestation the person forwarded to you. It carries six values about their activity across every app (event_count, human_event_count, lifetime_sats, distinct_integrator_count, oldest_event_at, active_days) and a 24h expiry, signed with the me.ochk.io receipt key, the same key that signs every receipt.
import { oc } from '@orangecheck/me-client';
const result = await oc.identity.verifyActivityAttestation(bundle);
if (!result.ok) return res.status(401).send(result.reason);
const a = result.attestation!;
if (a.lifetime_sats < 10_000) return res.status(403).send('low signal');
if (a.distinct_integrator_count < 3) return res.status(403).send('low breadth');
if (Date.parse(a.oldest_event_at!) > Date.now() - 90 * 86_400_000) {
return res.status(403).send('too new');
}
It runs five checks: bundle shape, the canonical-bytes hash matches the
content-addressed id, a key with the matching kid is in the receipt key set,
the Ed25519 signature, and now < expires_at. Each check is a row in
result.checks for logging.
The receipt key set at me.ochk.io/.well-known/oc-envelope-jwks.json is fetched
and cached for an hour when you pass no jwk. It is distinct from the key
set that signs webhook deliveries: same Ed25519 curve, different URL, cached
separately. Pass { jwk } to skip the fetch on edge runtimes or air-gapped
deployments.
The values are costly to fake (sats come from what apps really paid, breadth
needs separate apps, oldest_event_at can't be back-dated, active days need
time) and cheap to check: one call, one signature, the same on Node 20+, Vercel
Edge and the browser.
The same five checks run in the browser at ochk.app/verify-activity, for anyone who receives a forwarded attestation and doesn't want to write the verifier. The SDK and the page give the same verdict on the same input.
Types
Every type comes from the main entry, @orangecheck/me-client:
EventClass · EventSubtype · ClassASubtype · ClassBSubtype · ClassCSubtype
AttestTier · SiteFeeShape · IntegratorEventConfig · IntegratorPriceConfig
ComputedFees · ValidationResult · BillableEvent · Session · SessionPolicy
SignInOptions · PaymentAuthorizeOptions · PaymentResult · TelemetryEvent
FireEventOptions · FireEventResult · OcPublicJwk · VerifyOptions · VerifyResult
Scope · GrantedOptions · GrantedResult · RequestOptions
/server adds SessionPayload, BondClaim, VerifyOcOptions,
WithOcAuthOptions and OcHonoEnv. The class names (ClassASubtype and so on)
are the API's names for the three groups: One-time, Per action and Per session.
Constants and helpers
| Export | Signature |
|---|---|
PLATFORM_FEE_POLICY | { pct: 0.2; min_floor_sats: 1; ratified: string } |
MIN_INTEGRATOR_PRICE_SATS | 1 |
computeFees(cfg, payment_amount_sats?) | (IntegratorEventConfig, number?) => ComputedFees |
validateIntegratorConfig(cfg) | (IntegratorPriceConfig) => ValidationResult |
setBearerToken(token) | (string | null) => void · a token for later calls |
getBearerToken() / clearBearerToken() | () => string | null / () => void |
MIN_INTEGRATOR_PRICE_SATS is the smallest price the server takes. At 1 sat the
whole price is the fee, so the smallest earning is 1 sat, and you pay 2.
Where it lives
- npm: @orangecheck/me-client
- source: oc-packages/me-client
- types: the main entry,
@orangecheck/me-client, mirrorsoc-me-web/src/lib/events/types.ts. There is no/typessubpath.