oc · docs
sdk reference

@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: the oc.* 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

  • OcSignInButton is gone. It was the only React code in the main entry, so a Node backend that imported oc crashed looking for react. Sign in with signInWithOc() from /popup. react, react-dom and @orangecheck/auth-client are no longer peer dependencies.
  • A projectKey that names no project answers 500 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.ocSession is typed on Express's own Request, and new Hono<OcHonoEnv>() types c.get('ocSession'). No casts.
  • oc.webhook.verify takes a missing header (as req.header() returns it) and answers ok: 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.

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:

OptionWhat it does
audienceYour 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.
projectKeyEnforces 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.
requiredtrue 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,
});
FunctionSignatureNote
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);
FunctionSignatureNote
oc.config.validate(cfg)(IntegratorPriceConfig) => ValidationResultNo 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-signer to issue or revoke agent delegations from your integration, and @orangecheck/agent-core to 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

ScopeRevealsUse case
bitcoin_addressMaster did:bip322:bc1q… (BIP-322 users only)On-chain holdings verification, payment-channel co-signing
emailEmail address (email-OTP users only)Transactional email · receipts, password reset
attest_tierDeprecated: anonymous / bondedNone; see below
display_nameA 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:

  1. Update SDK to ^0.10.0.
  2. Switch from event.user_address → event.sub for user records in your database.
  3. If you actually need the user's email or address, call oc.scope.request(['email']) and read event.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.

FunctionSignatureNote
oc.scope.granted(opts)(GrantedOptions) => Promise<GrantedResult>What the signed-in person shares with your project now.
oc.scope.request(scopes, opts)(Scope[], RequestOptions) => neverNavigates 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);
FunctionSignatureNote
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

ExportSignature
PLATFORM_FEE_POLICY{ pct: 0.2; min_floor_sats: 1; ratified: string }
MIN_INTEGRATOR_PRICE_SATS1
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