Backend SDKs that submit Cekat events and correlate them with the browser visitor. Implemented: Go, Node.js and Bun, Python, PHP, Ruby, Java, and .NET.
Every SDK works the same way::
- Install the package for your language.
- Create one client with your Cekat access token (server-side only; never ship it to a browser) and send events: a common event such as
user_login, or a custom event with your own key. - Add the middleware for your web framework.
Why the middleware? The Cekat browser SDK remembers each anonymous visitor in the _cekat_visitor_id cookie (or sends it as the X-Cekat-Visitor-ID header to cross-origin APIs). The middleware reads that value on every request, so any event you send while handling the request carries the visitor ID automatically. When an event has both the visitor ID and an email or phone number, Cekat links the anonymous visitor to that contact.
Without middleware, read the cookie yourself and pass it as the event's visitor ID, as each section below shows. Visitor IDs come from the browser and are untrusted: use them only for this correlation, never for authentication.
Go
Install
go get golang.cekat.ai/event-sdk
go get golang.cekat.ai/event-sdk/middleware/gin # only the adapter for your framework: gin, echo, fiber, or chiSend events
import cekat "golang.cekat.ai/event-sdk"
client, err := cekat.New(os.Getenv("CEKAT_ACCESS_TOKEN"))
// Common events
_, err = client.UserLogin(r.Context(), cekat.Event{Email: "ada@example.com"})
_, err = client.FormSubmitted(r.Context(), cekat.Event{Email: "ada@example.com"})
// Custom event
_, err = client.CustomEvent(r.Context(), "trial_started", cekat.Event{
Email: "ada@example.com",
Properties: map[string]any{"plan": "pro"},
})Middleware (pass the request's context to the client, as shown)
// net/http — import cekatnethttp "golang.cekat.ai/event-sdk/middleware/nethttp"
mux.Handle("/login", cekatnethttp.Middleware(http.HandlerFunc(login))) // client.UserLogin(r.Context(), ...)
// Gin — import cekatgin "golang.cekat.ai/event-sdk/middleware/gin"
router.Use(cekatgin.Middleware()) // client.UserLogin(c.Request.Context(), ...)
// Echo — import cekatecho "golang.cekat.ai/event-sdk/middleware/echo"
e.Use(cekatecho.Middleware()) // client.UserLogin(c.Request().Context(), ...)
// Fiber — import cekatfiber "golang.cekat.ai/event-sdk/middleware/fiber"
app.Use(cekatfiber.Middleware()) // client.UserLogin(c.Context(), ...)
// Chi — import cekatchi "golang.cekat.ai/event-sdk/middleware/chi"
r.Use(cekatchi.Middleware) // client.UserLogin(r.Context(), ...)Without middleware
visitorID := ""
if cookie, err := r.Cookie("_cekat_visitor_id"); err == nil {
visitorID = cookie.Value
}
_, err = client.UserLogin(r.Context(), cekat.Event{Email: "ada@example.com", VisitorID: visitorID})More: go/README.md
Node.js and Bun
Install
npm install @cekatai/event-sdk # or: bun add @cekatai/event-sdkSend events
import { Client } from '@cekatai/event-sdk';
const cekat = new Client(process.env.CEKAT_ACCESS_TOKEN!);
// Common events
await cekat.userLogin({ email: 'ada@example.com' });
await cekat.formSubmitted({ email: 'ada@example.com' });
// Custom event
await cekat.customEvent('trial_started', { email: 'ada@example.com', properties: { plan: 'pro' } });Middleware
// Express
import { visitorMiddleware } from '@cekatai/event-sdk/express';
app.use(visitorMiddleware());
// Fastify
import { visitorPlugin } from '@cekatai/event-sdk/fastify';
await app.register(visitorPlugin);
// Koa
import { visitorMiddleware as cekatVisitor } from '@cekatai/event-sdk/koa';
app.use(cekatVisitor());
// NestJS (in your module's configure(consumer))
import { CekatVisitorMiddleware } from '@cekatai/event-sdk/nestjs';
consumer.apply(CekatVisitorMiddleware).forRoutes('*');
// Next.js, Node runtime only. Pages Router, pages/api/login.ts:
import { withCekatVisitor } from '@cekatai/event-sdk/nextjs';
export default withCekatVisitor(async (req, res) => { await cekat.userLogin({ email: req.body.email }); res.end(); });
// Next.js App Router, app/api/login/route.ts (also add: export const runtime = 'nodejs'):
import { runWithCekatVisitor } from '@cekatai/event-sdk/nextjs';
export async function POST(request: Request) {
return runWithCekatVisitor(request, async () => { await cekat.userLogin({ email: 'ada@example.com' }); return new Response('ok'); });
}
// Bun.serve, Hono, Elysia
import { withCekatVisitor as withVisitor, runWithCekatVisitor as runWithVisitor } from '@cekatai/event-sdk/fetch';
Bun.serve({ fetch: withVisitor(app.fetch) }); // wraps any fetch handler, including Hono and Elysia apps
honoApp.use((c, next) => runWithVisitor(c.req.raw, next)); // or as Hono middlewareWithout middleware (Express with cookie-parser)
await cekat.userLogin({ email: 'ada@example.com', visitorId: req.cookies._cekat_visitor_id });More: node/README.md
Python
Install
pip install cekat-event-sdk # extras: "cekat-event-sdk[django]", [flask], [asgi], or [fastapi]Send events
import os
from cekat_event_sdk import Client, Event
cekat = Client(os.environ["CEKAT_ACCESS_TOKEN"]) # AsyncClient offers the same methods with await
# Common events
cekat.user_login(Event(email="ada@example.com"))
cekat.form_submitted(Event(email="ada@example.com"))
# Custom event
cekat.custom_event("trial_started", Event(email="ada@example.com", properties={"plan": "pro"}))Middleware
# Django: settings.py
MIDDLEWARE = [
# ...
"cekat_event_sdk.integrations.django.DjangoVisitorMiddleware",
]
# Flask
from cekat_event_sdk.integrations.flask import CekatVisitor
CekatVisitor(app)
# FastAPI and Starlette
from cekat_event_sdk.integrations.asgi import VisitorMiddleware
app.add_middleware(VisitorMiddleware)Without middleware
visitor_id = request.cookies.get("_cekat_visitor_id") # Django: request.COOKIES.get("_cekat_visitor_id")
cekat.user_login(Event(email="ada@example.com", visitor_id=visitor_id))More: python/README.md
PHP
Install
composer require cekat/event-sdkSend events
use Cekat\EventSdk\Client;
use Cekat\EventSdk\EventInput;
$cekat = new Client(getenv('CEKAT_ACCESS_TOKEN'));
// Common events
$cekat->userLogin(new EventInput(email: 'ada@example.com'));
$cekat->formSubmitted(new EventInput(email: 'ada@example.com'));
// Custom event
$cekat->customEvent('trial_started', new EventInput(email: 'ada@example.com', properties: ['plan' => 'pro']));Middleware
// Laravel: set 'cekat' => ['access_token' => env('CEKAT_ACCESS_TOKEN')] in config/services.php,
// then in bootstrap/app.php (inject Cekat\EventSdk\Client where you send events):
use Cekat\EventSdk\Integration\Laravel\VisitorMiddleware;
->withMiddleware(function (Middleware $middleware) {
$middleware->append(VisitorMiddleware::class);
})
// PSR-15 (Slim, Mezzio, and others)
use Cekat\EventSdk\Integration\Psr15\VisitorMiddleware as CekatVisitorMiddleware;
$app->add(new CekatVisitorMiddleware());# Symfony: config/services.yaml
services:
Cekat\EventSdk\Context\VisitorContextInterface:
class: Cekat\EventSdk\Context\VisitorContext
Cekat\EventSdk\Client:
arguments:
$accessToken: '%env(CEKAT_ACCESS_TOKEN)%'
$visitorContext: '@Cekat\EventSdk\Context\VisitorContextInterface'
Cekat\EventSdk\Integration\Symfony\VisitorContextKernel:
decorates: http_kernel
arguments: ['@.inner', '@Cekat\EventSdk\Context\VisitorContextInterface']Without middleware
$cekat->userLogin(new EventInput(email: 'ada@example.com', visitorId: $_COOKIE['_cekat_visitor_id'] ?? null));More: php/README.md
Java
Install (Maven; use cekat-event-sdk-jakarta-servlet for a plain servlet app, or cekat-event-sdk-core for the client alone)
<dependency>
<groupId>ai.cekat</groupId>
<artifactId>cekat-event-sdk-spring-boot</artifactId>
<version>0.4.0</version>
</dependency>Send events
CekatClient cekat = new CekatClient(System.getenv("CEKAT_ACCESS_TOKEN"));
// Common events
cekat.userLogin(Event.builder().email("ada@example.com").build());
cekat.formSubmitted(Event.builder().email("ada@example.com").build());
// Custom event
cekat.customEvent("trial_started", Event.builder().email("ada@example.com").property("plan", "pro").build());Middleware
# Spring Boot: the visitor filter registers automatically; set the token to get an injectable CekatClient bean
cekat.access-token=${CEKAT_ACCESS_TOKEN}// Jakarta Servlet
FilterRegistration.Dynamic filter = servletContext.addFilter("cekatVisitorFilter", new CekatVisitorFilter());
filter.setAsyncSupported(true);
filter.addMappingForUrlPatterns(EnumSet.of(DispatcherType.REQUEST, DispatcherType.ASYNC, DispatcherType.ERROR), false, "/*");Without middleware (Spring MVC)
@PostMapping("/login")
void login(@CookieValue(name = "_cekat_visitor_id", required = false) String visitorId) throws InterruptedException {
cekat.userLogin(Event.builder().email("ada@example.com").visitorId(visitorId).build());
}More: java/README.md
.NET
Install
dotnet add package Cekat.EventSdk.AspNetCore # or Cekat.EventSdk.AzureFunctions, or Cekat.EventSdk for the client aloneSend events
using Cekat.EventSdk;
var cekat = new CekatClient(new CekatClientOptions { AccessToken = Environment.GetEnvironmentVariable("CEKAT_ACCESS_TOKEN") });
// Common events
await cekat.UserLoginAsync(new EventInput(Email: "ada@example.com"));
await cekat.FormSubmittedAsync(new EventInput(Email: "ada@example.com"));
// Custom event
await cekat.CustomEventAsync("trial_started", new EventInput(Email: "ada@example.com", Properties: new Dictionary<string, object?> { ["plan"] = "pro" }));Middleware
// ASP.NET Core: Program.cs (inject CekatClient into your endpoints)
builder.Services.AddCekatEventSdk(options => options.AccessToken = builder.Configuration["Cekat:AccessToken"]);
var app = builder.Build();
app.UseCekatVisitor();
// Azure Functions (isolated worker): Program.cs
var functions = FunctionsApplication.CreateBuilder(args);
functions.UseCekatVisitor();
functions.Services.AddCekatEventSdk(options => options.AccessToken = Environment.GetEnvironmentVariable("CEKAT_ACCESS_TOKEN"));
functions.Build().Run();Without middleware
await cekat.UserLoginAsync(new EventInput(Email: "ada@example.com", VisitorId: httpContext.Request.Cookies["_cekat_visitor_id"]));More: dotnet/README.md
Ruby
Install
bundle add cekat-event-sdkSend events
require "cekat_event_sdk"
CEKAT = CekatEventSdk::Client.new(access_token: ENV.fetch("CEKAT_ACCESS_TOKEN"))
# Common events
CEKAT.user_login(email: "ada@example.com")
CEKAT.form_submitted(email: "ada@example.com")
# Custom event
CEKAT.custom_event("trial_started", email: "ada@example.com", properties: { plan: "pro" })Middleware
# Rails: nothing to add; the middleware is inserted automatically.
# Create the client in config/initializers/cekat.rb as shown above.
# Rack (Sinatra, Hanami, Roda, and others): config.ru
use CekatEventSdk::Rack::MiddlewareWithout middleware (Rails controller)
CEKAT.user_login(email: "ada@example.com", visitor_id: cookies[:_cekat_visitor_id])More: ruby/README.md
The middleware also prefers a nonblank X-Cekat-Visitor-ID header over the cookie. If you read the value yourself for cross-origin requests, check that header first, then the cookie.
- SDK contract: client settings, the request and payload, the six operations (including
form_submitted), acknowledgements, and error categories shared by every SDK. - Visitor propagation: how the browser visitor ID reaches events, precedence, the trust boundary, and each framework integration.
- Retries and errors: timeouts, retry rules,
Retry-After, duplicates, response classification, and cancellation. - Compatibility: supported runtimes and frameworks, generated from
ci/compatibility-matrix.json. - Release checklist: version rechecks, release readiness, and the manual publication gates.
- Shared conformance: the executable contract every SDK must pass.
Every SDK runs the same language-neutral fixtures (conformance/fixtures/cases) against a real HTTP mock of the ingest API (conformance/mock-ingest-server). See conformance/README.md for the contract.
scripts/conformance.sh # every language that has a runner
scripts/conformance.sh --language php # one languageFor each language, the script builds the Go mock server (or uses MOCK_INGEST_SERVER_BIN), starts a private instance on a random loopback port, waits for its readiness record and control API, runs <language>/scripts/conformance with exactly the four CEKAT_CONFORMANCE_* variables, and stops the server. It requires Go, python3, and each language's own toolchain. A language passes only when its runner exits 0, its mock server stays up, and the runner's result lines account for every fixture exactly once (passed, or not_applicable where the fixture declares that language inapplicable); processes a runner leaves behind are stopped.
ci/compatibility-matrix.json records, for each SDK, the declared runtime floors, the CI versions, the observed exact versions, supported frameworks, and upcoming end-of-support dates. scripts/validate-compatibility-matrix.py fails when it disagrees with the package metadata, ci.yml, release-readiness.yml, or the language's evidence document, so a changed floor, CI row, or framework range must be updated in all of them together.
python3 scripts/validate-compatibility-matrix.py # drift checks (runs in CI)
python3 scripts/validate-compatibility-matrix.py --as-of "$(date -u +%F)" --max-age-days 30 --markdown # release gates and summaryWith --as-of, it also fails when a supported line has reached end of support or the evidence is older than the given age, and warns about lines ending within 90 days. docs/compatibility.md is generated from the matrix with python3 scripts/audit-docs.py --write-compatibility.
python3 scripts/audit-docs.py checks the root README, the documents under docs/, the conformance guide, and the seven language READMEs: links and anchors, required contract terms, exact protocol names, unfinished markers, token-like secrets, publication claims, and that each README names its matrix runtime floor and frameworks. It runs in CI.
.github/workflows/ci.yml runs on every pull request and on pushes to main: the conformance contract and mock server tests, then minimum and current runtime profiles for each SDK (unit, integration, and shared conformance). The CI required job succeeds only when every other job succeeds, so it is the single check to require in branch protection.
Before any release, run the Release readiness workflow (.github/workflows/release-readiness.yml, manual trigger only). It builds every SDK with its own scripts/package on the current toolchain, validates each manifest.json and the complete seven-language set, and keeps the artifacts for 14 days with a summary table of file names, sizes, and SHA-256 hashes.
Locally (each language's toolchain must be installed):
scripts/package-readiness.sh --language node --output /absolute/empty/dir # one language, into <dir>/node
scripts/package-readiness.sh --all --output /absolute/empty/dir # all seven, then aggregate validation
python3 scripts/validate-package-manifest.py --all /absolute/empty/dir # re-check an existing set
python3 -m unittest discover -s scripts/tests -p 'test_*.py' # root script testsci/package-manifest.schema.json documents the manifest format; scripts/validate-package-manifest.py enforces it, including the exact file set, sizes, and hashes.
Each release starts the same way: a release owner pushes that language's version tag on a commit that is already on main and green. The workflow then rebuilds the package with scripts/package-readiness.sh, checks the tag against the version declared in the package metadata, and waits for approval in a GitHub environment before anything leaves the repository. No registry credentials are stored: npm, PyPI, and RubyGems all authenticate with trusted publishing, using a short-lived token issued for that one run.
| Language | Tag | Workflow | Environment | What the approved job does |
|---|---|---|---|---|
| Go | go/vX.Y.Z |
release-go.yml |
go-release |
Checks that every adapter requires the core version being released, creates the four go/middleware/*/vX.Y.Z tags at the same commit, publishes a GitHub Release per module, and asks the public module proxy for each module path. Go has no registry upload: the tags are the release. |
| Node.js | node/vX.Y.Z |
release-node.yml |
npm |
Verifies the tarball's name, version, and hashes, then publishes it to npm with provenance. |
| Python | python/vX.Y.Z |
release-python.yml |
pypi |
Verifies the manifest, then uploads the wheel and sdist to PyPI with attestations. |
| Ruby | ruby/vX.Y.Z |
release-ruby.yml |
rubygems |
Verifies the manifest and the name and version inside the gem, then pushes that gem file to RubyGems. |
| Java | java/vX.Y.Z |
release-java.yml |
maven-central |
Verifies the manifest, signs every file with the release key, adds checksums, and uploads the bundle to the Sonatype Portal. It stops at VALIDATED: a release owner presses Publish, because a Maven Central version can never be replaced. |
| PHP | php/vX.Y.Z |
release-php.yml |
packagist |
Mirrors this tag's php/ directory to cekataiofficial/cekat-event-sdk-php, where composer.json sits at the repository root, and tags it vX.Y.Z for Packagist to read. |
| .NET | dotnet/vX.Y.Z |
release-dotnet.yml |
nuget |
Verifies the manifest, exchanges the job's OIDC token for a one-hour key, and pushes the three Cekat.EventSdk packages to nuget.org. |
Every one of these refuses a version that already exists in the registry, so a re-run cannot overwrite a release. npm, PyPI, RubyGems, and NuGet authenticate with trusted publishing and store no credentials; Maven Central and Packagist offer none, so those two workflows read secrets from their own approval-gated environments.
The release checklist covers the registry setup for each language, the per-release steps, and the gates that stay manual, such as changelog approval and the final Publish click for Maven Central.
