Documentazione per sviluppatori
Piattaforma per sviluppatori Cerchia: webhook, API REST v1 e Accedi con Cerchia
Per creator e agenzie che vogliono i dati di accesso di Cerchia nei propri sistemi: webhook in uscita a ogni cambio di accesso e a ogni vendita, un'API REST in sola lettura con chiavi API per creator, e "Accedi con Cerchia" per far entrare i fan sul sito del creator.
Crea chiavi API, endpoint webhook e app OAuth nell'area creator di Cerchia, in Sviluppatori. Una chiave API si vede una volta sola, quando la crei. Il segreto di firma di un endpoint si può rivedere o ruotare lì.
Tutto riguarda un solo creator: una chiave legge solo i membri e le vendite del suo creator; un endpoint riceve solo gli eventi del suo creator; un'app vede solo i fan del suo creator. Il creator resta il venditore; Cerchia riporta solo quello che sa già.
Webhook
Eventi
| Evento | Quando | data |
|---|---|---|
member.joined | Un fan ottiene l'accesso (nuovo membro, o di ritorno dopo che l'accesso era finito) | member, change |
member.recovered | L'accesso stava finendo (tolleranza) ed è di nuovo attivo, per esempio un pagamento non riuscito è stato pagato | member, change |
member.grace | L'accesso sta finendo: pagamento non riuscito, abbonamento finito, iscrizione alla piattaforma finita o livello abbassato. Il fan mantiene l'accesso per i giorni di tolleranza del creator | member, change |
member.left | L'accesso è finito: il membro esce dal gruppo nello stesso momento | member, change |
sale.created | Un pagamento è riuscito. sale.renewal è true quando il membro aveva già pagato | sale |
sale.refunded | Un pagamento è stato rimborsato | sale |
test | Inviato su richiesta ("Invia un evento di prova" nell'area creator), mai in abbonamento | {} |
Un rinnovo è un pagamento, non un cambio di accesso: arriva come sale.created con renewal: true, e l'accesso resta active.
Contenuto
{
"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" }
}
}idè stabile per l'evento: lo stesso su ogni endpoint e a ogni nuovo tentativo. Deduplica su questo valore: una consegna può arrivare più di una volta.created_atè il momento del cambio. L'ordine non è garantito: un evento ritentato può arrivare dopo uno più recente. Quando l'ordine conta, leggi lo stato attuale conGET /api/v1/members/{id}.memberviene letto quando la consegna viene preparata, non quando è avvenuto il cambio.member.accessè la risposta attuale del motore degli accessi;changeè quello che l'evento ha registrato. Possono essere diversi (un fan entrato e uscito un minuto dopo).- Gli oggetti member e sale sono gli stessi dell'API REST.
Intestazioni e firma
POST <il tuo URL>
Content-Type: application/json
User-Agent: Cerchia-Webhooks/1.0 (+https://cerchia.app/developers)
Cerchia-Event: member.joined
Cerchia-Delivery: 5b6e… (una per endpoint ed evento; la stessa nei nuovi tentativi)
Cerchia-Signature: t=1763028001,v1=5f2b…v1 è l'HMAC-SHA256 esadecimale di "<t>.<corpo grezzo>", con chiave il segreto di firma dell'endpoint (l'intera stringa whsec_…, in UTF-8). t è l'ora Unix del tentativo. Rifiuta una richiesta se la firma non corrisponde o se t è più vecchio di 5 minuti.
Verifica in Node (qui con Express; va bene qualsiasi framework, purché calcoli l'hash del corpo grezzo, prima di leggerlo come JSON):
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);
// Deduplica su event.id, poi gestisci event.type. Rispondi subito; il lavoro lento va in coda.
res.sendStatus(204);
});Consegna, nuovi tentativi e disattivazione
- Qualsiasi risposta 2xx entro 10 secondi è un successo. I redirect non vengono seguiti (un 3xx è un errore).
- I tentativi non riusciti vengono ripetuti dopo 1 minuto, 5 minuti, 15 minuti, 1, 2, 4, 8 e 8 ore: 9 tentativi in circa 23 ore. Poi la consegna è segnata come non riuscita.
- 410 Gone ferma tutto subito e disattiva l'endpoint.
- Un endpoint viene disattivato quando una consegna rinuncia e l'endpoint non ha avuto consegne riuscite da allora (circa 22 ore o più). Le sue consegne in attesa vengono annullate. Un endpoint disattivato non riceve più niente finché non lo riattivi nell'area creator.
- Dall'area creator puoi anche inviare un evento di prova, far partire subito le consegne in attesa di un nuovo tentativo, riprovare una volta una consegna non riuscita o annullata, e mettere in pausa un endpoint.
- Le consegne partono ogni 2 minuti: aspettati un webhook da 1 a 3 minuti dopo il cambio.
- Le consegne restano visibili 30 giorni.
API REST v1
URL di base: https://cerchia.app/api/v1. Solo da server a server: non mettere mai una chiave in un browser o in un'app. JSON in entrata, JSON in uscita.
Autenticazione e permessi
Authorization: Bearer ck_…| Permesso | Endpoint |
|---|---|
members:read | GET /members, GET /members/{id} |
access:read | GET /access/check |
sales:read | GET /sales |
Limite di richieste
120 richieste al minuto per chiave (finestra fissa). Oltre il limite: 429 con Retry-After in secondi.
Errori
{ "error": { "type": "forbidden", "message": "This API key lacks the sales:read scope" } }| Stato | type |
|---|---|
| 400 | invalid_request (parametro o cursore non valido) |
| 401 | unauthorized (chiave mancante, malformata, sconosciuta o revocata) |
| 403 | forbidden (permesso mancante) |
| 404 | not_found |
| 429 | rate_limited |
| 500, 503 | server_error |
I messaggi di errore dell'API sono in inglese.
Paginazione
Gli elenchi partono dal più recente e restituiscono:
{ "object": "list", "data": [ … ], "has_more": true, "next_cursor": "WyIyMDI2…" }Passa ?cursor=<next_cursor> per la pagina successiva, ?limit= da 1 a 100 (predefinito 50). I cursori sono opachi.
GET /members
I membri del creator da entrambe le fonti: Incassa (abbonamenti pagati; i checkout mai pagati sono esclusi) e Verifica (membri YouTube, Twitch, Patreon, Kick). Gli ex membri sono inclusi con access: "none". Facoltativo ?source=incassa|verifica.
GET /members/{id}
Un membro per id (abbonamento Incassa o iscrizione Verifica). Il membro di un altro creator è un 404.
GET /access/check
Questo fan ha accesso adesso? Uno dei due:
?email=fan@example.it: membri Incassa con questa email (senza distinzione tra maiuscole e minuscole).?platform=twitch|patreon|youtube|kick&platform_user_id=…: membri Verifica con questo id utente della piattaforma (id utente Twitch, id utente Patreon, id del canale YouTube, id utente Kick).
{ "object": "access_check", "access": "active", "members": [ { "object": "member", … } ] }access è il migliore tra members: active, poi grace (ha ancora accesso, sta finendo), poi none. Un fan sconosciuto al creator riceve none e un elenco vuoto, la stessa risposta di qualsiasi non membro.
GET /sales
Le vendite del creator (pagamenti riusciti e rimborsati), dalla più recente.
Oggetti
Member
| Campo | Incassa | Verifica |
|---|---|---|
object | "member" | "member" |
id | id dell'abbonamento | id dell'iscrizione Verifica |
source | "incassa" | "verifica" |
access | active, grace o none, in tempo reale dal motore degli accessi | uguale |
status | active, grace, canceled, expired, paused | active, grace, expired |
grace_until | fine della tolleranza, quando l'accesso sta finendo | uguale |
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 (id dei livelli della piattaforma all'ultimo controllo), member_since, verified_at |
Sale
object ("sale"), id, member_id, plan_id, email, status (succeeded, refunded), amount_cents, currency, application_fee_cents (la commissione di Cerchia, trattenuta dal fornitore di pagamento), payment_provider, renewal, paid_at, created_at.
Mai inclusi: id Telegram, token di piattaforme o di pagamento, dati grezzi dei fornitori.
Accedi con Cerchia
"Accedi con Cerchia" permette al sito del creator di far entrare i fan con il loro account Cerchia e di leggere il loro accesso con quel creator. Il fan dimostra una volta sola su Cerchia di essere membro (un pagamento con Cerchia, oppure YouTube, Twitch o Patreon con Verifica), approva cosa vedrà il sito e torna già dentro. Il sito non chiama mai YouTube, Twitch o Patreon e non ha bisogno di nessuna verifica dell'app.
È OpenID Connect: il flusso authorization code con PKCE, per app con un server che custodisce un client secret. Registra un'app nell'area creator, Sviluppatori, sezione Accedi con Cerchia: un nome mostrato ai fan e gli indirizzi di ritorno (redirect URI) esatti. Ricevi un client ID (cc_app_…) e un client secret (cs_live_…, mostrato una volta sola; quando lo ruoti, il vecchio funziona ancora per 24 ore).
Flusso
1. Il tuo server crea uno state casuale, un nonce casuale e una coppia PKCE (code_verifier, da 43 a 128 caratteri; code_challenge = base64url del suo SHA-256), li tiene nella sessione del fan e manda il browser all'endpoint di autorizzazione con response_type=code, client_id, redirect_uri, scope, state, nonce, code_challenge e code_challenge_method=S256. 2. Il fan entra su Cerchia (link via email, oppure Verifica se non ha un account Cerchia) e vede cosa leggerà il tuo sito. Quando approva, Cerchia se lo ricorda: agli accessi successivi torna subito indietro. 3. Cerchia rimanda al tuo redirect_uri con code, il tuo state e iss=https://cerchia.app. Controlla che state corrisponda e che iss sia quello di Cerchia. Il codice vale 60 secondi, una volta sola. 4. Il tuo server manda il codice all'endpoint token con lo stesso redirect_uri, il code_verifier e le credenziali dell'app (HTTP Basic, oppure client_id e client_secret nel corpo). Ricevi un ID token e un access token. 5. Verifica l'ID token (firma dal JWKS, iss, aud = il tuo client ID, exp, nonce) e leggi l'accesso del fan dal claim cerchia_access. Per una risposta aggiornata in seguito, chiama userinfo con l'access token.
Se il fan annulla, ricevi error=access_denied con il tuo state. Con prompt=none ricevi login_required o consent_required invece di una pagina.
Endpoint
| Cosa | URL |
|---|---|
| Configurazione | https://cerchia.app/.well-known/openid-configuration |
| Autorizzazione | 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 |
| Revoca | https://cerchia.app/api/oauth/revoke |
Issuer: https://cerchia.app. Firma: solo ES256. Valori di prompt supportati: none, login, consent; max_age e ui_locales (it, en) sono rispettati.
Scope e claim
| Scope | Claim |
|---|---|
openid (obbligatorio) | sub: lo stesso per un fan su tutte le app di un creator, diverso per ogni creator. Non rivela nessun id di Cerchia |
email | email, email_verified (sempre true: il fan è entrato con un link mandato a quell'indirizzo). Assenti per i fan entrati solo con Verifica: non hanno un'email su Cerchia |
access:read | cerchia_access: l'accesso del fan con il creator dell'app, e niente sugli altri creator |
L'ID token e l'access token portano i claim com'erano all'accesso; userinfo li rilegge ogni volta.
Il claim cerchia_access
{
"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(accesso che sta finendo: la tolleranza del creator dopo un pagamento non riuscito o un abbonamento scaduto, oppure il periodo pagato dopo una disdetta) onone. Il migliore tra i requisiti.requirements: una voce per ogni cosa che il fan ha: un piano Incassa, oppure il livello più alto su un account YouTube, Twitch o Patreon.includeselenca i livelli a cui corrisponde ("Il Circolo" include "Sostenitore"), quindi "Sostenitore o superiore" èincludesche contiene Sostenitore.ends_at: quando finisce l'accesso se nulla cambia;nullse non c'è una fine prevista.destinations: i gruppi Telegram, le liste email e le pagine riservate del creator, con l'indicazione se il fan rispetta le regole di accesso di ciascuno.
Token e revoca
- ID token e access token sono JWT ES256 validi 10 minuti. Non ci sono refresh token: rimanda il fan all'endpoint di autorizzazione (con il consenso ricordato torna subito;
prompt=nonelo rende invisibile). - L'access token ha il tipo JWT
at+jwt,aud= il tuo client ID,client_idescope. Non accettare mai un access token come ID token, né il contrario. POST /api/oauth/revokecontoken=<access token>e le credenziali dell'app fa rifiutare a userinfo gli access token di quel fan per la tua app; il consenso del fan resta. Quando il fan scollega il tuo sito dal suo account, o il creator disattiva l'app, userinfo rifiuta i token e l'accesso successivo chiede di nuovo il consenso.- Codici, token e segreti viaggiano solo nel corpo delle richieste e nell'intestazione
Authorization: userinfo rifiuta?access_token=, l'endpoint token rifiuta parametri nell'URL. - Chiavi: scarica il JWKS e tienilo in cache; per un token con un
kidche non conosci, scaricalo di nuovo. Durante una rotazione delle chiavi il JWKS ne elenca due.
Esempio: Next.js con l'SDK
L'SDK (@cerchia/sdk) gestisce il flusso, i controlli sui token e la firma dei webhook.
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: inizia l'accesso
export async function GET() {
const { verifier, challenge } = await createPkce();
const state = randomToken();
const nonce = randomToken();
// Tieni state, nonce e verifier in un cookie HttpOnly (o nella tua sessione).
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: concludilo
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("Richiesta non valida", { 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));
// Qui avvia la tua sessione per claims.sub.
return Response.redirect(new URL("/area-riservata", url));
}