Developer documentation

Cerchia developer platform: webhooks, REST API v1 and Accedi con Cerchia

API keys and webhook endpoints are created in the creator area.

Open Developers

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

EventWhendata
member.joinedA fan gains access (new member, or back after access ended)member, change
member.recoveredAccess was ending (grace) and is active again, e.g. a failed payment was paidmember, change
member.graceAccess is ending: payment failed, subscription ended, platform membership ended or tier lowered. The fan keeps access for the creator's tolerance periodmember, change
member.leftAccess ended: the member is removed from the group at the same momentmember, change
sale.createdA payment succeeded. sale.renewal is true when the member had paid beforesale
sale.refundedA payment was refundedsale
testSent 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" }
  }
}
  • id is stable for the event: the same on every endpoint and every retry. Deduplicate on it; a delivery can arrive more than once.
  • created_at is when the change happened. Order is best effort: a retried event can arrive after a newer one. When order matters, read the current state with GET /api/v1/members/{id}.
  • member is read when the delivery is built, not when the change happened. member.access is the live answer of the entitlement engine; change is 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_…
ScopeEndpoints
members:readGET /members, GET /members/{id}
access:readGET /access/check
sales:readGET /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" } }
Statustype
400invalid_request (bad parameter or cursor)
401unauthorized (missing, malformed, unknown or revoked key)
403forbidden (missing scope)
404not_found
429rate_limited
500, 503server_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

FieldIncassaVerifica
object"member""member"
idmembership idVerifica membership id
source"incassa""verifica"
accessactive, grace or none, live from the entitlement enginesame
statusactive, grace, canceled, expired, pausedactive, grace, expired
grace_untilend of the tolerance period, when endingsame
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 (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

WhatURL
Discoveryhttps://cerchia.app/.well-known/openid-configuration
Authorizationhttps://cerchia.app/api/oauth/authorize
Tokenhttps://cerchia.app/api/oauth/token
Userinfohttps://cerchia.app/api/oauth/userinfo
JWKShttps://cerchia.app/api/oauth/jwks
Revocationhttps://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

ScopeClaims
openid (required)sub: the same for a fan on all of a creator's apps, different for every creator. It reveals no Cerchia id
emailemail, 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:readcerchia_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) or none. 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. includes lists the levels it counts as ("Il Circolo" includes "Sostenitore"), so "Sostenitore or higher" is includes containing Sostenitore. ends_at: when access ends if nothing changes; null when 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=none keeps it invisible).
  • The access token has the JWT type at+jwt, aud = your client ID, client_id and scope. Never accept an access token as an ID token, or the other way round.
  • POST /api/oauth/revoke with token=<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 Authorization header: userinfo refuses ?access_token=, the token endpoint refuses parameters in its URL.
  • Keys: fetch the JWKS and cache it; on a token whose kid you 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));
}