Quickstart
Goal: a real sybil-gate decision in one command.
You need curl. Not a wallet, not an account, not an API key — checking is a
public read.
1. Ask the gate about an address
curl -s 'https://attest.ochk.io/api/check?addr=bc1pezn5yjxmz9nuahtqprvhfeya2nv4zdfk49u5amquhqptykx695rs60hqa2'
{
"ok": true,
"sats": 10000,
"days": 307,
"score": 103.46,
"attestation_id": "bbde80f884ae849d2cc8d4fa19687e7ec862f8b7c85a3ee8cf93739548e23658",
"address": "bc1pezn5yjxmz9nuahtqprvhfeya2nv4zdfk49u5amquhqptykx695rs60hqa2",
"identities": [
{
"protocol": "nostr",
"identifier": "npub1yfk7w33n60g05whqdmzluaftan390q89mqhht67wf4r8y90m3h9spyzpyn"
}
],
"network": "mainnet"
}
That is the whole product in one response. This address has proved control of an
unspent 10,000-sat output that has sat still for 307 days, and has bound a Nostr
identity to it. ok is the answer to "should I let this one in".
score is advisory — a convenience number, not a consensus value. Gate on
sats and days, which are facts about the chain. See
Scoring for why.
2. Set your own bar
ok is computed against thresholds you pass. Nothing is hardcoded.
# a floor this address clears
curl -s '.../api/check?addr=bc1pezn…&min_sats=5000&min_days=90'
{"ok": true, "sats": 10000, "days": 307, "score": 103.46}
# a floor it does not
curl -s '.../api/check?addr=bc1pezn…&min_sats=100000000&min_days=3650'
{"ok": false, "sats": 10000, "days": 307, "score": 103.46}
Note that a failing check still returns the facts. You decide what to do with a 10,000-sat, 307-day identity — the endpoint does not moralise about it.
An address that has never attested is a different answer — and it comes back with a 404, not a 200:
curl -s 'https://attest.ochk.io/api/check?addr=bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080'
{"ok": false, "reasons": ["not_found"]}
Read the status code, not just ok
There are four outcomes and the HTTP status separates two of them that ok
alone does not:
| meaning | status | body |
|---|---|---|
| clears your bar | 200 | ok: true plus the facts |
| attested, below your bar | 200 | ok: false plus the facts |
| no attestation exists | 404 | ok: false, reasons: ["not_found"] |
| malformed request | 400 | error: "bad_request", issues[] |
This matters more than it looks. The obvious client is wrong:
// WRONG — throws on a perfectly normal "this address never attested"
const res = await fetch(url);
if (!res.ok) throw new Error('check failed');
const { ok } = await res.json();
// Right — 404 is an answer, not a failure.
const res = await fetch(url);
if (res.status === 400) throw new Error('bad address');
if (res.status >= 500) throw new Error('verifier unavailable');
const body = await res.json(); // 200 and 404 both carry usable JSON
const admit = body.ok === true; // 404 lands here as false, correctly
"No proof exists" and "proof exists and is too small" are different facts about a person, and a gate usually wants to treat them differently — the first is a stranger, the second is someone who showed up with too little.
3. Look up by identity instead of address
If what you hold is a Nostr npub rather than a Bitcoin address, ask by identity.
The value is protocol:identifier — a bare npub is rejected.
curl -s 'https://attest.ochk.io/api/check?identity=nostr:npub1yfk7w33n60g05whqdmzluaftan390q89mqhht67wf4r8y90m3h9spyzpyn'
{"ok": true, "sats": 10000, "days": 307, "address": "bc1pezn…"}
4. See everything an address has published
curl -s 'https://attest.ochk.io/api/discover?addr=bc1pezn…&limit=5'
Returns every attestation for that subject, each with its attestation_id,
scheme (bip322), and bound identities. Use it when you want the history
rather than a yes/no.
Then: gate your own thing
You now have the read path. The three next steps, in the order most people want them:
- Gate an Express route — the check above as drop-in middleware, about ten lines.
- Sybil-filter a Nostr relay — a Strfry plugin that rejects events from unproofed npubs.
- Filter an airdrop — turn a candidate list into a sybil-resistant allowlist.
Verify without trusting us
Everything above is a convenience over a pure function. /api/check is
attest.ochk.io reading a public index; if you would rather not trust that,
don't.
npm i @orangecheck/sdk@^1.7.0
The version floor matters. In 1.3.0 and earlier, check() filtered relays on
a multi-letter #address tag, which NIP-12 relays do not index — so it answered
not_found for every real attestation. Before 1.7.0, it took the subject from
relay tags rather than the signed message, and let one bond vouch for several
identities on the same protocol.
import { check } from '@orangecheck/sdk';
const result = await check({
addr: 'bc1pezn5yjxmz9nuahtqprvhfeya2nv4zdfk49u5amquhqptykx695rs60hqa2',
minSats: 5000,
minDays: 90,
});
// { ok: true, sats: 10000, days: 307, score: 103.46 }
Same shape as the HTTP response, and no call to attest.ochk.io — check()
reads the attestations off Nostr relays and re-derives the answer locally from
the envelope plus public chain data. Pass relays to point it at your own. That
is the whole point of the protocol, and the reason the HTTP API is optional
rather than load-bearing.
verify({ msg, addr, sig }) is the lower-level primitive if you hold a raw
signed message rather than a published envelope, and createAttestation()
builds and signs a new one.
To check an envelope you already hold without installing anything:
curl -s -X POST https://attest.ochk.io/api/verify \
-H 'content-type: application/json' \
-d '{"envelope": { … }, "policy": {"min_sats": 5000, "min_days": 90}}'
envelope is the full canonical AttestationEnvelope JSON as produced by
createAttestation(). See Verification for the exact
checks a verifier performs, and
Self-host the verifier to run the whole stack
yourself.
Get your own attestation
Signing needs a wallet that can do BIP-322 (UniSat, Xverse, Leather, Sparrow, Electrum, OKX, Phantom). Open attest.ochk.io, connect it, and sign one short canonical message — your Bitcoin never moves and no private key leaves your wallet. How it works is the whole protocol on one page.
The machine-readable contract
curl -s https://attest.ochk.io/api/openapi
OpenAPI 3.1, covering /api/check, /api/verify, /api/discover,
/api/publish-attestation and /api/stats. Generate a client from it in any
language. (/api/stats reports a rolling 10-minute window, so zeros there
mean "quiet right now", not "empty".)