Developer documentation
Cerchia developer platform: webhooks, REST API v1 and Accedi con Cerchia
For creators and agencies who want Cerchia's access data in their own systems: outbound webhooks on every access change and sale, a read-only REST API with per-creator API keys, and "Accedi con Cerchia" to sign fans in on the creator's own site.
Create API keys, webhook endpoints and OAuth apps in the Cerchia creator app, under Developers. An API key is shown once, when you create it. An endpoint's signing secret can be shown again or rotated there.
Everything is scoped to one creator: a key reads only its creator's members and sales; an endpoint receives only its creator's events; an app sees only its creator's fans. The creator stays the seller; Cerchia only reports what it already knows.
Webhooks
Events
| Event | When | data |
|---|---|---|
member.joined | A fan gains access (new member, or back after access ended) | member, change |
member.recovered | Access was ending (grace) and is active again, e.g. a failed payment was paid | member, change |
member.grace | Access is ending: payment failed, subscription ended, platform membership ended or tier lowered. The fan keeps access for the creator's tolerance period | member, change |
member.left | Access ended: the member is removed from the group at the same moment | member, change |
sale.created | A payment succeeded. sale.renewal is true when the member had paid before | sale |
sale.refunded | A payment was refunded | sale |
test | Sent on request ("Send a test event" in the creator app), never subscribed | {} |
A renewal is a payment, not an access change: it arrives as sale.created with renewal: true, and access stays active.
Payload
{
"id": "evt_1234",
"type": "member.joined",
"created_at": "2026-11-13T10:00:01.123456+00:00",
"data": {
"member": {
"object": "member",
"id": "8f0c…",
"source": "incassa",
"access": "active",
"status": "active",
"plan_id": "b1d2…",
"email": "fan@example.it",
"payment_provider": "stripe",
"current_period_end": "2026-12-13T10:00:00+00:00",
"grace_until": null,
"cancel_at_period_end": false,
"activated_at": "2026-11-13T10:00:00+00:00",
"canceled_at": null,
"created_at": "2026-11-13T09:58:12+00:00"
},
"change": { "from": "none", "to": "active" }
}
}idis stable for the event: the same on every endpoint and every retry. Deduplicate on it; a delivery can arrive more than once.created_atis when the change happened. Order is best effort: a retried event can arrive after a newer one. When order matters, read the current state withGET /api/v1/members/{id}.memberis read when the delivery is built, not when the change happened.member.accessis the live answer of the entitlement engine;changeis what the event recorded. They can differ (a fan who joined and left a minute later).- The member and sale objects are the same as in the REST API.
Headers and signature
POST <your URL>
Content-Type: application/json
User-Agent: Cerchia-Webhooks/1.0 (+https://cerchia.app/developers)
Cerchia-Event: member.joined
Cerchia-Delivery: 5b6e… (one per endpoint and event; the same on retries)
Cerchia-Signature: t=1763028001,v1=5f2b…v1 is the hex HMAC-SHA256 of "<t>.<raw body>", keyed with the endpoint's signing secret (the whole whsec_… string, as UTF-8). t is the Unix time of the attempt. Reject a request whose signature doesn't match or whose t is more than 5 minutes old.
Verify in Node (Express shown; any framework works as long as you hash the raw body, before JSON parsing):
import crypto from "node:crypto";
import express from "express";
const SECRET = process.env.CERCHIA_WEBHOOK_SECRET; // whsec_…
function verifyCerchia(rawBody, header, toleranceSeconds = 300) {
if (!header) return false;
const parts = Object.fromEntries(header.split(",").map((p) => p.trim().split("=")));
const t = Number(parts.t);
if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", SECRET).update(`${t}.${rawBody}`).digest("hex");
const given = String(parts.v1 ?? "");
return given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
const app = express();
app.post("/cerchia", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verifyCerchia(raw, req.get("Cerchia-Signature"))) return res.sendStatus(400);
const event = JSON.parse(raw);
// Deduplicate on event.id, then handle event.type. Answer fast; do slow work in a queue.
res.sendStatus(204);
});Delivery, retries and disabling
- Any 2xx answer within 10 seconds is a success. Redirects are not followed (a 3xx is a failure).
- Failed attempts are retried after 1 minute, 5 minutes, 15 minutes, 1, 2, 4, 8 and 8 hours: 9 attempts over about 23 hours. Then the delivery is marked failed.
- 410 Gone stops at once and disables the endpoint.
- An endpoint is turned off when a delivery gives up and the endpoint has had no successful delivery since (about 22 hours or more). Its pending deliveries are canceled. A turned-off endpoint receives nothing until you turn it back on in the creator app.
- From the creator app you can also send a test event, make the deliveries waiting for a retry go out now, retry a failed or canceled delivery once, and pause an endpoint.
- Deliveries go out every 2 minutes: expect a webhook 1 to 3 minutes after the change.
- Deliveries are kept 30 days.
REST API v1
Base URL: https://cerchia.app/api/v1. Server to server only: never put a key in a browser or an app. JSON in, JSON out.
Authentication and scopes
Authorization: Bearer ck_…| Scope | Endpoints |
|---|---|
members:read | GET /members, GET /members/{id} |
access:read | GET /access/check |
sales:read | GET /sales |
Rate limit
120 requests per minute per key (fixed window). Over it: 429 with Retry-After in seconds.
Errors
{ "error": { "type": "forbidden", "message": "This API key lacks the sales:read scope" } }| Status | type |
|---|---|
| 400 | invalid_request (bad parameter or cursor) |
| 401 | unauthorized (missing, malformed, unknown or revoked key) |
| 403 | forbidden (missing scope) |
| 404 | not_found |
| 429 | rate_limited |
| 500, 503 | server_error |
Pagination
Lists are newest first and return:
{ "object": "list", "data": [ … ], "has_more": true, "next_cursor": "WyIyMDI2…" }Pass ?cursor=<next_cursor> for the next page, ?limit= from 1 to 100 (default 50). Cursors are opaque.
GET /members
The creator's members from both sources: Incassa (paid memberships; never-paid checkouts are left out) and Verifica (YouTube, Twitch, Patreon, Kick members). Past members are included with access: "none". Optional ?source=incassa|verifica.
GET /members/{id}
One member by id (Incassa membership or Verifica membership). Another creator's member is a 404.
GET /access/check
Does this fan have access now? One of:
?email=fan@example.it: Incassa members with this email (case-insensitive).?platform=twitch|patreon|youtube|kick&platform_user_id=…: Verifica members with this platform user id (Twitch user id, Patreon user id, YouTube channel id, Kick user id).
{ "object": "access_check", "access": "active", "members": [ { "object": "member", … } ] }access is the best among members: active, then grace (still has access, ending), then none. A fan unknown to this creator gets none and an empty list, the same answer as any non-member.
GET /sales
The creator's sales (succeeded and refunded payments), newest first.
Objects
Member
| Field | Incassa | Verifica |
|---|---|---|
object | "member" | "member" |
id | membership id | Verifica membership id |
source | "incassa" | "verifica" |
access | active, grace or none, live from the entitlement engine | same |
status | active, grace, canceled, expired, paused | active, grace, expired |
grace_until | end of the tolerance period, when ending | same |
created_at | ||
plan_id, email, payment_provider (stripe, satispay), current_period_end, cancel_at_period_end, activated_at, canceled_at | platform, platform_user_id, tier_ids (platform tier ids held at the last check), member_since, verified_at |
Sale
object ("sale"), id, member_id, plan_id, email, status (succeeded, refunded), amount_cents, currency, application_fee_cents (Cerchia's fee, taken by the payment provider), payment_provider, renewal, paid_at, created_at.
Never included: Telegram ids, platform or payment tokens, raw provider payloads.
Accedi con Cerchia
"Accedi con Cerchia" (Sign in with Cerchia) lets the creator's own site sign fans in with their Cerchia account and read their access with that creator. The fan proves their membership once at Cerchia (a payment through Cerchia, or YouTube, Twitch or Patreon through Verifica), approves what the site will see, and comes back signed in. The site never calls YouTube, Twitch or Patreon and needs no app verification of its own.
It is OpenID Connect: the authorization code flow with PKCE, for apps with a server that keeps a client secret. Register an app in the creator app, Developers, section Accedi con Cerchia: a name shown to fans and the exact redirect URIs. You get a client ID (cc_app_…) and a client secret (cs_live_…, shown once; rotating it keeps the old one working for 24 hours).
Flow
1. Your server creates a random state, a random nonce and a PKCE pair (code_verifier, 43–128 characters; code_challenge = base64url of its SHA-256), keeps them in the fan's session, and redirects the browser to the authorization endpoint with response_type=code, client_id, redirect_uri, scope, state, nonce, code_challenge and code_challenge_method=S256. 2. The fan signs in at Cerchia (email link, or Verifica when they have no Cerchia account) and sees what your site will read. Once they approve, Cerchia remembers it: the next sign-ins come straight back. 3. Cerchia redirects to your redirect_uri with code, your state and iss=https://cerchia.app. Check that state matches and iss is Cerchia's. The code is valid for 60 seconds, once. 4. Your server posts the code to the token endpoint with the same redirect_uri, the code_verifier and your client credentials (HTTP Basic, or client_id and client_secret in the body). You get an ID token and an access token. 5. Verify the ID token (signature from the JWKS, iss, aud = your client ID, exp, nonce) and read the fan's access from its cerchia_access claim. For a live answer later, call userinfo with the access token.
If the fan cancels, you get error=access_denied with your state. With prompt=none you get login_required or consent_required instead of a page.
Endpoints
| What | URL |
|---|---|
| Discovery | https://cerchia.app/.well-known/openid-configuration |
| Authorization | https://cerchia.app/api/oauth/authorize |
| Token | https://cerchia.app/api/oauth/token |
| Userinfo | https://cerchia.app/api/oauth/userinfo |
| JWKS | https://cerchia.app/api/oauth/jwks |
| Revocation | https://cerchia.app/api/oauth/revoke |
Issuer: https://cerchia.app. Signing: ES256 only. Supported prompt values: none, login, consent; max_age and ui_locales (it, en) are honoured.
Scopes and claims
| Scope | Claims |
|---|---|
openid (required) | sub: the same for a fan on all of a creator's apps, different for every creator. It reveals no Cerchia id |
email | email, email_verified (always true: the fan signed in with a link sent to it). Absent for fans who signed in with Verifica only: they have no email at Cerchia |
access:read | cerchia_access: the fan's access with the app's creator, and nothing about other creators |
The ID token and the access token carry the claims as they were at sign-in; userinfo reads them again each time.
The cerchia_access claim
{
"creator": "8f0c…",
"status": "active",
"requirements": [
{
"source": "youtube",
"status": "active",
"level": { "id": "3b1e…", "name": "Il Circolo", "rank": 2 },
"includes": [{ "id": "3b1e…", "name": "Il Circolo" }, { "id": "9a7d…", "name": "Sostenitore" }],
"ends_at": null
},
{
"source": "incassa",
"status": "grace",
"level": { "id": "c2d4…", "name": "Annuale", "rank": null },
"includes": [{ "id": "c2d4…", "name": "Annuale" }],
"ends_at": "2027-01-31T10:00:00.000Z"
}
],
"destinations": [{ "id": "5e9a…", "kind": "telegram_group", "name": "Soldi Chiari · Membri", "status": "active", "ends_at": null }],
"checked_at": "2027-01-12T09:30:00.000Z"
}status:active,grace(access that is ending: the creator's tolerance after a failed payment or a lapsed membership, or the paid period after a cancellation) ornone. The best of the requirements.requirements: one entry per thing the fan holds: an Incassa plan, or the highest level on a YouTube, Twitch or Patreon account.includeslists the levels it counts as ("Il Circolo" includes "Sostenitore"), so "Sostenitore or higher" isincludescontaining Sostenitore.ends_at: when access ends if nothing changes;nullwhen nothing is scheduled.destinations: the creator's Telegram groups, email lists and gated pages, with whether the fan meets each one's access rules.
Tokens and revocation
- ID and access tokens are ES256 JWTs valid for 10 minutes. There are no refresh tokens: send the fan through the authorization endpoint again (with remembered consent it comes straight back;
prompt=nonekeeps it invisible). - The access token has the JWT type
at+jwt,aud= your client ID,client_idandscope. Never accept an access token as an ID token, or the other way round. POST /api/oauth/revokewithtoken=<access token>and your client credentials makes userinfo refuse that fan's access tokens for your app; the fan's consent stays. When the fan disconnects your site from their account, or the creator turns the app off, userinfo refuses the tokens too and the next sign-in asks for consent again.- Codes, tokens and secrets travel only in request bodies and the
Authorizationheader: userinfo refuses?access_token=, the token endpoint refuses parameters in its URL. - Keys: fetch the JWKS and cache it; on a token whose
kidyou don't know, fetch it again. During a key rotation the JWKS lists two keys.
Example: Next.js with the SDK
The SDK (@cerchia/sdk) wraps the flow, the token checks and the webhook signature.
import { createCerchiaClient, createPkce, randomToken, hasAccess } from "@cerchia/sdk";
const cerchia = createCerchiaClient({ clientId: process.env.CERCHIA_CLIENT_ID!, clientSecret: process.env.CERCHIA_CLIENT_SECRET! });
const redirectUri = "https://example.com/auth/callback";
// app/auth/login/route.ts: start the sign-in
export async function GET() {
const { verifier, challenge } = await createPkce();
const state = randomToken();
const nonce = randomToken();
// Keep state, nonce and verifier in an HttpOnly cookie (or your session store).
return new Response(null, {
status: 302,
headers: {
location: cerchia.authorizationUrl({ redirectUri, state, nonce, codeChallenge: challenge }),
"set-cookie": `cerchia_auth=${state}.${nonce}.${verifier}; HttpOnly; Secure; SameSite=Lax; Path=/auth; Max-Age=600`,
},
});
}
// app/auth/callback/route.ts: finish it
export async function GET(request: Request) {
const url = new URL(request.url);
const [state, nonce, verifier] = (request.headers.get("cookie")?.match(/cerchia_auth=([^;]+)/)?.[1] ?? "").split(".");
if (!state || url.searchParams.get("state") !== state || url.searchParams.get("iss") !== cerchia.issuer) return new Response("Bad request", { status: 400 });
const tokens = await cerchia.exchangeCode({ code: url.searchParams.get("code") ?? "", redirectUri, codeVerifier: verifier });
const claims = await cerchia.verifyIdToken(tokens.id_token, { nonce });
if (!hasAccess(claims, { level: "Sostenitore" })) return Response.redirect(new URL("/abbonati", url));
// Start your own session for claims.sub here.
return Response.redirect(new URL("/area-riservata", url));
}