Documentation

End-to-end flow

One signup, complete: verify, persist eventId, challenge, solve, re-verify, then label the outcome.

The sequence

1. Verify at the action with a high-entropy, preferably server-generated session and an Idempotency-Key you generate, so retries of the same attempt return one verdict.

2. Persist `eventId` immediately on the pending signup / account row. Feedback joins only on that key: do not wait for allow.

3. When `decision` is `challenge`, or `degraded` is true, return HTTP 428, then issue friction with prefer in economic order: payment_preauth, proof_of_work, web_bot_auth_stepup.

4. Prove friction was satisfied (complete with challengeId + proof), then check the result. Completion clears session state only: it does not authorize the action. Expired (410 challenge_expired) or invalid proofs must not create the account: issue a fresh challenge. Replays return 409; a different session returns 403.

5. Re-verify with context.challengeId and a new idempotency key. Proceed only on non-degraded allow. When the clear is honored, challengeCleared: true is set and scoring is short-circuited to allow (never challenge + challengeCleared).

6. Create the account only on an explicit non-degraded `allow` (decision === "allow" and degraded === false). Deny stops; any other verdict stays on the challenge response (HTTP 428) and POST /v1/challenge flow.

7. Label the outcome when it lands: credit burn from usage metering, chargebacks from billing, abuse confirmations from your trust queue, see the outcome-label connectors guide.

Full code

Complete loop for one signup. Subject fields stay hashed by default (piiMode: "hashed"). solvePow / solve_pow is your own solver: hash candidate nonces with the issued seed until the hex digest starts with the required zero nibbles, see the tip below.

import { ChitmarkApiError } from "@chitmark/sdk";

const session = `sess_${crypto.randomUUID()}`;
const idempotencyKey = `signup:${session}`;

// 1. Verify at the action (email/ip/userAgent hashed client-side by default)
let verdict = await chitmark.verify({
  action: "signup",
  session,
  idempotencyKey, // required for safe retries of this attempt
  subject: { email, ip, userAgent },
});

// 2. Persist the join key immediately (before branching)
let eventId = verdict.eventId;
await db.signups.stashEventId({ session, eventId });

// 3. Challenge (or degraded): issue, solve, complete with explicit failure handling
if (verdict.decision === "challenge" || verdict.degraded) {
  const ch = await chitmark.challenge({
    eventId,
    session,
    prefer: ["payment_preauth", "proof_of_work"],
  });
  // Branch on ch.method: unavailable prefer entries fall back to proof_of_work.

  try {
    if (ch.method !== "proof_of_work") {
      throw new ChallengeRequiredError(); // route to your payment_preauth UI
    }
    const nonce = await solvePow(ch.instructions);
    const cleared = await chitmark.completeChallenge({
      eventId,
      challengeId: ch.challengeId,
      session,
      proof: { type: "proof_of_work", nonce },
    });
    if (!cleared.ok) {
      throw new ChallengeRequiredError(); // do not treat a soft failure as allow
    }
  } catch (err) {
    if (
      err instanceof ChitmarkApiError &&
      (err.code === "challenge_expired" ||
        err.code === "invalid_challenge_proof" ||
        err.code === "challenge_method_mismatch")
    ) {
      throw new ChallengeRequiredError(); // issue a fresh challenge; never create
    }
    throw err;
  }

  // 4. Re-verify so the next verdict honors the cleared session
  verdict = await chitmark.verify({
    action: "signup",
    session,
    idempotencyKey: `${idempotencyKey}:cleared`,
    context: { challengeId: ch.challengeId },
    subject: { email, ip, userAgent },
  });
  eventId = verdict.eventId;
  await db.signups.stashEventId({ session, eventId });
  // verdict.challengeCleared === true
}

// 5. Only an explicit, non-degraded allow creates the account
if (verdict.decision === "deny") {
  throw new BlockedError();
}
if (verdict.decision !== "allow" || verdict.degraded) {
  throw new ChallengeRequiredError();
}

const account = await db.accounts.create({ email, chitmarkEventId: eventId });

// ... later, when the outcome lands:
// 6. Label it, keyed by the stored event id
await chitmark.feedback({
  eventId: account.chitmarkEventId,
  outcome: "credit_burn",
  value: 12.4, // economic magnitude: burn $, chargeback $, or converted first-year ARR
  unit: "usd", // scale for value: usd | credits | count (default: usd)
});

Challenge issues and completions are two calls on the same verb, see the challenge API reference: issue with no proof, complete with challengeId and proof.