Documentazione per sviluppatori

Piattaforma per sviluppatori Cerchia: webhook, API REST v1 e Accedi con Cerchia

Chiavi API ed endpoint webhook si creano nell'area creator.

Apri Sviluppatori

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

EventoQuandodata
member.joinedUn fan ottiene l'accesso (nuovo membro, o di ritorno dopo che l'accesso era finito)member, change
member.recoveredL'accesso stava finendo (tolleranza) ed è di nuovo attivo, per esempio un pagamento non riuscito è stato pagatomember, change
member.graceL'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 creatormember, change
member.leftL'accesso è finito: il membro esce dal gruppo nello stesso momentomember, change
sale.createdUn pagamento è riuscito. sale.renewal è true quando il membro aveva già pagatosale
sale.refundedUn pagamento è stato rimborsatosale
testInviato 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 con GET /api/v1/members/{id}.
  • member viene 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_…
PermessoEndpoint
members:readGET /members, GET /members/{id}
access:readGET /access/check
sales:readGET /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" } }
Statotype
400invalid_request (parametro o cursore non valido)
401unauthorized (chiave mancante, malformata, sconosciuta o revocata)
403forbidden (permesso mancante)
404not_found
429rate_limited
500, 503server_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

CampoIncassaVerifica
object"member""member"
idid dell'abbonamentoid dell'iscrizione Verifica
source"incassa""verifica"
accessactive, grace o none, in tempo reale dal motore degli accessiuguale
statusactive, grace, canceled, expired, pausedactive, grace, expired
grace_untilfine della tolleranza, quando l'accesso sta finendouguale
created_at
plan_id, email, payment_provider (stripe, satispay), current_period_end, cancel_at_period_end, activated_at, canceled_atplatform, 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

CosaURL
Configurazionehttps://cerchia.app/.well-known/openid-configuration
Autorizzazionehttps://cerchia.app/api/oauth/authorize
Tokenhttps://cerchia.app/api/oauth/token
Userinfohttps://cerchia.app/api/oauth/userinfo
JWKShttps://cerchia.app/api/oauth/jwks
Revocahttps://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

ScopeClaim
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
emailemail, 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:readcerchia_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) o none. 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. includes elenca i livelli a cui corrisponde ("Il Circolo" include "Sostenitore"), quindi "Sostenitore o superiore" è includes che contiene Sostenitore. ends_at: quando finisce l'accesso se nulla cambia; null se 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=none lo rende invisibile).
  • L'access token ha il tipo JWT at+jwt, aud = il tuo client ID, client_id e scope. Non accettare mai un access token come ID token, né il contrario.
  • POST /api/oauth/revoke con token=<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 kid che 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));
}