Documentation

Framework drop-ins

Copy in verified integrations for Express, Next.js App Router, Cloudflare Workers, or any stack with curl.

Three different uses of "challenge" in integrations:

TermMeaning
Challenge responseVerify said friction is warranted: your app returns HTTP 428 (decision: "challenge" or degraded) and does not continue.
Issue frictionPOST /v1/challenge without proof: create friction; returns method + instructions. Does not clear the session.
Prove friction satisfiedPOST /v1/challenge with challengeId + proof: clears only the bound session, once (never identity, account, IP, or device). Does not authorize the action.
Re-verifyPOST /v1/verify with context.challengeId: decision engine stays authoritative; proceed only on non-degraded allow.

Chitmark decides when friction is warranted; you choose which mechanism via prefer. Framework drop-ins normalize transient failures, challenge responses, and this HTTP mapping for you. Reach for raw status codes only with the low-level client or curl (see Errors).

Every drop-in below is just this contract. Store eventId on the account row at allow time: feedback joins only on that id, see the outcome-label connectors guide.

const v = await chitmark.verify(...);

if (v.degraded || v.decision === "challenge") {
  return challenge(v.eventId); // HTTP 428
}

if (v.decision === "deny") {
  return deny(); // HTTP 403
}

if (v.decision !== "allow") {
  return challenge(v.eventId); // fail closed
}

// Only an explicit non-degraded allow reaches the handler.
return handler(v.eventId);

Express middleware

chitmarkGate(client, action, options) wraps an Express route: the verdict lands on res.locals.chitmark, and your handler only runs on an eligible allow.

The Python SDK is framework-agnostic (no middleware): apply the same decision mapping in your route handler, as shown on the FastAPI tab.

import { Chitmark, chitmarkGate } from "@chitmark/sdk";

const chitmark = new Chitmark({ apiKey: process.env.CHITMARK_API_KEY! });

app.post(
  "/signup",
  chitmarkGate(chitmark, "signup"),
  async (req, res) => {
    // reachable only on an explicit, non-degraded allow
    const { eventId } = res.locals.chitmark as { eventId: string };
    const user = await createAccount(req.body, { chitmarkEventId: eventId });
    res.json({ userId: user.id });
  },
);

// Bind challenges and verdict tokens to a session:
app.post(
  "/trial",
  chitmarkGate(chitmark, "trial_activation", {
    session: (req) => req.body.sessionId,
  }),
  trialHandler,
);

Next.js App Router

A route handler maps the verdict to a response itself, so you control the status from the same file that stores the account. Use getTrustedClientIp(req) for the subject IP: it prefers platform headers and only falls back to X-Forwarded-For behind a trusted proxy boundary. On Cloudflare, prefer verifyRequest from @chitmark/sdk/edge, which owns that lookup. Next.js is a TypeScript ecosystem; Python web frameworks use the FastAPI-style mapping from the Express section.

// app/api/signup/route.ts
import {
  Chitmark,
  CHITMARK_HTTP_STATUS,
  getTrustedClientIp,
  isEligibleAllow,
} from "@chitmark/sdk";

const chitmark = new Chitmark({ apiKey: process.env.CHITMARK_API_KEY! });

export async function POST(req: Request) {
  const body = (await req.json()) as { email?: string };

  const v = await chitmark.verify({
    action: "signup",
    surface: "app.acme.com/signup",
    subject: {
      email: body.email,
      // X-Forwarded-For only via getTrustedClientIp, behind a trusted proxy.
      ip: getTrustedClientIp(req),
      userAgent: req.headers.get("user-agent") ?? "",
      headers: Object.fromEntries(req.headers),
      formData: body,
    },
  });

  if (v.degraded || v.decision === "challenge") {
    return Response.json(
      { challenge: true, eventId: v.eventId },
      { status: CHITMARK_HTTP_STATUS.preconditionRequired },
    );
  }
  if (v.decision === "deny") {
    return Response.json(
      { error: "blocked" },
      { status: CHITMARK_HTTP_STATUS.forbidden },
    );
  }
  if (!isEligibleAllow(v)) {
    // Unknown / malformed decision: fail to challenge, never to allow.
    return Response.json(
      { challenge: true, eventId: v.eventId },
      { status: CHITMARK_HTTP_STATUS.preconditionRequired },
    );
  }

  // Only an explicit non-degraded allow reaches here.
  // Store eventId now: every later outcome joins on it.
  const user = await createAccount(body, { chitmarkEventId: v.eventId });
  return Response.json({ ok: true, userId: user.id });
}

Cloudflare Worker

Verify before fetch reaches your origin. The @chitmark/sdk/edge entry's verifyRequest reads the request's IP (cf-connecting-ip first), user agent, headers, and Web Bot Auth signatures for you, so you do not hand-parse X-Forwarded-For. On an allow, stamp the verdict headers for your origin.

import { CHITMARK_HTTP_STATUS, Chitmark, isEligibleAllow } from "@chitmark/sdk/edge";

interface Env {
  CHITMARK_API_KEY: string;
}

export default {
  async fetch(req: Request, env: Env): Promise<Response> {
    if (new URL(req.url).pathname !== "/signup") {
      return fetch(req);
    }

    const chitmark = new Chitmark({ apiKey: env.CHITMARK_API_KEY });
    const v = await chitmark.verifyRequest(req, { action: "signup" });

    if (v.degraded || v.decision === "challenge") {
      return Response.json(
        { challenge: true, eventId: v.eventId },
        { status: CHITMARK_HTTP_STATUS.preconditionRequired },
      );
    }
    if (v.decision === "deny") {
      return new Response("blocked", {
        status: CHITMARK_HTTP_STATUS.forbidden,
      });
    }
    if (!isEligibleAllow(v)) {
      // Unknown / malformed decision: fail to challenge, never to allow.
      return Response.json(
        { challenge: true, eventId: v.eventId },
        { status: CHITMARK_HTTP_STATUS.preconditionRequired },
      );
    }

    // Only an explicit non-degraded allow reaches here.
    const fwd = new Request(req);
    fwd.headers.set("x-chitmark-principal", v.principal.onBehalfOf ?? "unknown");
    fwd.headers.set("x-chitmark-event", v.eventId);
    return fetch(fwd);
  },
};

Any stack with curl

No SDK dependency required. Hash PII before it leaves your process (SHA-256 the email, truncate the IP); raw values are accepted only with tenant opt-in.

curl -s https://api.chitmark.com/v1/verify \
  -H "Authorization: Bearer $CHITMARK_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: signup:sess_9f3a" \
  -d '{
    "action": "signup",
    "session": "sess_9f3a",
    "surface": "app.acme.com/signup",
    "subject": {
      "emailSha256": "9c1185a5c5e9fc...",
      "ipTrunc": "203.0.113.0/24",
      "userAgent": "...",
      "formDataHashed": { "companyDomain": "acmecorp.com" }
    }
  }'

Send feedback when outcomes land

Feedback is asynchronous by design. Keep the request path thin and the outcome path separate:

PathFlow
Request pathverify → allow / challenge / deny
Outcome pathaccount + eventId → outcome → feedback

That separation is load-bearing: protection does not need to know synchronously whether the signup later burned credits, converted, or became abusive. Economics stay explainable because labels arrive when the business fact is known, from any service that holds the key (usage metering, billing, trust queue), never during the signup request.

Every drop-in above stores eventId on the account row for that join. Call POST /v1/feedback with the stored id when the result arrives, see the outcome-label connectors guide.

// Outcome path: later, when the label matures (queue / cron / webhook)
await chitmark.feedback({
  eventId: user.chitmarkEventId, // stored at verify time in every drop-in above
  outcome: "credit_burn", // credit_burn | abuse_confirmed | chargeback | converted | ...
  value: 12.4, // economic magnitude: burn $, chargeback $, or converted first-year ARR
  unit: "usd", // scale for value: usd | credits | count (default: usd)
});