AI Context

Give your AI coding assistant everything it needs to help you integrate ExpressConsent correctly.

How to use

Copy the prompt below and paste it into your AI assistant (ChatGPT, Claude, Cursor, Copilot Chat, etc.) at the start of a conversation. It contains a condensed version of the full ExpressConsent documentation — SDK setup, captureCDR() options and return values, consent language detection, highlighting, masking, sessions, sharing, the server-to-server API, webhooks, and rendering fidelity tips.

Once pasted, the AI will have the context it needs to write correct integration code, troubleshoot issues, and answer questions about ExpressConsent without you having to explain the system from scratch.

Prompt

ExpressConsent AI Context Prompt
ExpressConsent SDK & API — Complete Integration Context

ExpressConsent captures full-page visual screenshots of webpages at the moment of form submission to create tamper-proof consent evidence (TCPA compliance, email consent, etc.). Each piece of evidence is called a Certified Digital Record (CDR). A CDR contains: a full-page visual screenshot, signer telemetry (IP, User-Agent, geolocation), timestamps, session stats, and developer-supplied custom metadata. CDRs are stored for 5 years.

────────────────────────────────────────
1. SDK SETUP
────────────────────────────────────────

Add this script tag in <head>:

  <script src="https://sdk.expressconsent.com/sdk/v1/sdk.js" data-ec-cid="YOUR_CID"></script>

- Replace YOUR_CID with the CID from the dashboard (Organization → Settings).
- Do NOT self-host the SDK — it resolves its upload endpoint from the URL it was loaded from. Self-hosting breaks uploads.
- The SDK attaches itself to window.ExpressConsent. The window. prefix is required in TypeScript and linted JS.
- Check if loaded: window.ExpressConsent (object) and window.ExpressConsent.version (string).

────────────────────────────────────────
2. captureCDR() — FULL REFERENCE
────────────────────────────────────────

Signature: window.ExpressConsent.captureCDR(options?: CaptureCDROptions): Promise<CaptureCDRResult>

CRITICAL: Must be awaited before page navigation or form submission. If the page navigates before the promise resolves, the CDR may not be saved.

Recommended pattern:

  form.addEventListener("submit", async (e) => {
    e.preventDefault();
    try {
      const { cdrId } = await window.ExpressConsent.captureCDR({
        custom: { leadId: "123", phoneNumber: "+15551234567" },
      });
      console.log("Evidence saved:", cdrId);
    } catch (err) {
      console.error("Capture failed:", err);
    }
    e.currentTarget.submit();
  });

OPTIONS (all optional):

  custom: object
    Arbitrary JSON metadata stored with the CDR. Max 16 KB. Makes CDRs searchable via API (metadataKey/metadataValue query params) and visible in dashboard + webhook payload as customMetadata.

  highlightTarget: string
    Override auto-highlighting. Must exactly match a data-expressconsent-highlight-label attribute on a DOM element. Only use if auto-highlighting selects the wrong element.

  triggerEvent: Event
    Pass the DOM Event object from the handler for maximum forensic precision. Uses event.target to identify the trigger element and event.isTrusted to prove it was a real user action. If omitted, the SDK auto-detects the trigger.

  subGroupIds: string[]
    Create sub-group Package CDRs for co-registration flows. Max 5 unique non-empty strings. Each sub-group Package CDR contains only CDRs tagged with that ID.

  autoShare: true | { expiresInMs: number }
    Auto-generate a share URL for lead buyers during upload (no separate API call).
    - true → 30-day expiry (default)
    - { expiresInMs: 604800000 } → custom expiry (max 2 years / 63072000000 ms)
    When enabled, result includes shareUrl, shareToken, shareExpiresAt.

  inlineAssets: boolean
    For localhost/dev only. Embeds images, fonts, and styles directly in the snapshot payload so cloud rendering works without needing to fetch your local URLs. Increases payload size. Keep off in production.

  timeoutMs: number
    Override the SDK's default adaptive timeout budget (milliseconds).

RETURN VALUE (CaptureCDRResult):

  {
    cdrId: string,                    // Always present. The evidence ID — store this with your lead/user record.
    packageData: {                    // Always present. Session grouping info.
      packageId: string,              // The session-level package ID.
      subGroupIdMap: Record<string, string>  // Maps each subGroupId to its package ID.
    },
    // Present only when autoShare is enabled:
    shareUrl: string,                 // Full absolute URL, e.g. "https://api-next.expressconsent.com/v1/shares/xYz..."
    shareToken: string,               // Raw share token (last segment of the URL).
    shareExpiresAt: number            // Token expiry as Unix timestamp in milliseconds.
  }

ERROR BEHAVIOR:
Throws when persistence cannot be guaranteed:
- SDK not loaded (missing script tag or data-ec-cid attribute)
- Network failure or timeout
- Upload endpoint unreachable (e.g. from self-hosting the SDK)
Always wrap in try/catch. Log the error and let the form submit proceed. Do NOT block form submission on capture failure.

AUTOMATIC SIGNALS (no code needed):
- IP address and User-Agent are captured from the upload request automatically.
- These appear in the CDR as signer telemetry.

────────────────────────────────────────
3. HIGHLIGHTING
────────────────────────────────────────

The SDK automatically highlights the element the user interacted with to trigger the capture (e.g. the submit button). A visible outline is drawn around that element in the screenshot. No code needed for most integrations. The detection does not affect the live page.

BEST PRACTICES:
- Call captureCDR() as the FIRST async operation in the handler. Async work (API calls, validation) BEFORE captureCDR() reduces highlight accuracy because the SDK falls back to its interaction log.
- For strongest forensic proof, pass triggerEvent: e to captureCDR(). Uses event.isTrusted which browsers guarantee cannot be forged.

MANUAL OVERRIDE (rare):
1. Add attribute to HTML: <button data-expressconsent-highlight-label="consent-submit">I agree</button>
2. Pass to captureCDR: await window.ExpressConsent.captureCDR({ highlightTarget: "consent-submit" });
The SDK matches the first element with that label.

TROUBLESHOOTING HIGHLIGHTING:
- No highlight: No user interaction before capture (programmatic call), or element has no identifiable attributes (add an id), or element is inside a cross-origin iframe (invisible to SDK).
- Wrong element: Async work before captureCDR(), or duplicate id attributes in the HTML.

────────────────────────────────────────
4. MASKING
────────────────────────────────────────

Add data-expressconsent-mask to any element to permanently redact its content before evidence leaves the browser. Inherited by descendants.

  <input name="ssn" data-expressconsent-mask />

  <section data-expressconsent-mask>
    <input name="cardNumber" />
    <input name="cvv" />
  </section>

Masked data is gone permanently — cannot be recovered. Only mask what is strictly necessary (payment details, government IDs). NEVER mask consent language, checkboxes, or submit buttons — that defeats the purpose of the evidence.

────────────────────────────────────────
5. SESSIONS & PACKAGE CDRs
────────────────────────────────────────

- A CDR is one piece of evidence (one page snapshot). CDRs are the billable unit.
- A Session groups CDRs from the same browser tab. Managed automatically by the SDK.
- A Package CDR bundles CDRs from the same session into a composite evidence document for multi-step consent flows. Package CDRs are a grouping/presentation concept with no billing significance.

Multiple captureCDR() calls in the same tab automatically share a session.

SUB-GROUPS (co-registration):
Use subGroupIds to create additional Package CDRs within the same session. Each sub-group Package CDR contains only the CDRs tagged with that ID. Useful when different lead buyers should see different pages.

  await window.ExpressConsent.captureCDR({ subGroupIds: ["buyer-a", "buyer-b"] });

PITFALLS:
- Different browser tabs = different sessions (and separate Package CDRs).
- Long idle gaps between steps can roll the session, splitting the flow.

────────────────────────────────────────
6. SHARING EVIDENCE WITH LEAD BUYERS
────────────────────────────────────────

Roles:
- Lead generator (producer): runs the website, implements SDK, calls captureCDR(), sends share URL to buyer.
- Lead buyer (claimer): purchases leads, claims share URL via POST with their own API key.

OPTION 1 — Auto-share at capture time (recommended):
  const result = await window.ExpressConsent.captureCDR({
    autoShare: true,
    custom: { leadId: "your-internal-lead-id" },
  });
  // result.shareUrl → "https://api-next.expressconsent.com/v1/shares/xYz..."
  // Pass result.shareUrl to your lead buyer along with the lead data.

OPTION 2 — Share via API (server-side, after the fact):
  POST /v1/cdrs/:cdrId/share (see API section below)

END-TO-END FLOW:
1. Consumer submits form on lead generator's site.
2. Lead generator calls captureCDR({ autoShare: true }) → gets cdrId + shareUrl.
3. Lead generator posts the lead to the buyer's CRM, including the shareUrl.
4. Lead buyer calls POST shareUrl with their X-API-Key header to claim the CDR.
5. Evidence now appears under both organizations. Buyer can view/download in dashboard or via API.

BILLING: Once any party collects (pays for) a CDR, all other parties with access do NOT pay again. Typically the lead buyer pays, but it's up to the partnership.

CO-REGISTRATION & MULTIPLE BUYERS: Each buyer claims the same share URL independently. For different pages per buyer, use subGroupIds.

────────────────────────────────────────
7. SERVER-TO-SERVER API — FULL REFERENCE
────────────────────────────────────────

Base URL: https://api-next.expressconsent.com
Auth header: X-API-Key: <keyId>.<secret>
Generate keys in dashboard (Organization → API Keys). Secret shown only once. NEVER expose in client-side code.

RESPONSE ENVELOPE:
Success: { "ok": true, "data": { ... }, "requestId": "uuid" }
Error:   { "ok": false, "error": { "code": "ERROR_CODE", "message": "Human-readable", "requestId": "uuid" } }

ERROR CODES:
- UNAUTHENTICATED (401): missing, malformed, or invalid API key
- FORBIDDEN (403): authenticated but not authorized for this resource
- INVALID_ARGUMENT (400): bad query/path input
- NOT_FOUND (404): resource doesn't exist or isn't accessible
- METHOD_NOT_ALLOWED (405): wrong HTTP method
- CONFLICT (409): conflicting state (e.g. duplicates)
- GONE (410): resource expired (e.g. share token past expiry)
- FAILED_PRECONDITION (412): action requires a prior step

─── GET /v1/domains ───
List all domains for your organization.

  curl -H "X-API-Key: $EC_API_KEY" "$EC_API_BASE_URL/v1/domains"

─── GET /v1/domains/:domainId/cdrs ───
List CDRs for a domain with pagination and optional metadata filtering.

Query params:
- pageSize: 1–100 (default 20)
- order: "asc" | "desc" (default "desc")
- pageToken: CDR ID to start after (for pagination)
- metadataKey + metadataValue: filter by custom metadata (both required together)

  curl -H "X-API-Key: $EC_API_KEY" "$EC_API_BASE_URL/v1/domains/example.com/cdrs?pageSize=20&order=desc"
  curl -H "X-API-Key: $EC_API_KEY" "$EC_API_BASE_URL/v1/domains/example.com/cdrs?metadataKey=leadId&metadataValue=123"

Response:
  {
    "ok": true,
    "data": {
      "cdrs": [
        {
          "cdrId": "abc_123",
          "domainId": "example.com",
          "domain": "example.com",
          "createdAt": 1738600000000,
          "contentType": "image/jpeg",
          "size": 276472,
          "collected": true,
          "downloadUrl": "https://storage.googleapis.com/... (short-lived signed URL)",
          "customMetadata": { "leadId": "123" },
          "sessionId": "session_abc",
          "subGroupIds": ["step-1"]
        }
      ],
      "nextPageToken": "abc_122"
    }
  }

─── GET /v1/cdrs/:cdrId ───
Get a single CDR with full detail including signer telemetry and geolocation.
For CDRs received via sharing, includes guest: true and producerOrgId.

  curl -H "X-API-Key: $EC_API_KEY" "$EC_API_BASE_URL/v1/cdrs/abc_123"

Response:
  {
    "ok": true,
    "data": {
      "cdr": {
        "cdrId": "abc_123",
        "domainId": "example.com",
        "domain": "example.com",
        "organizationName": "Acme Corp",
        "capturedAt": 1738600000000,
        "createdAt": 1738600000000,
        "contentType": "image/jpeg",
        "size": 276472,
        "collected": true,
        "downloadUrl": "https://storage.googleapis.com/... (short-lived signed URL)",
        "pageUrl": "https://example.com/consent",
        "signerTelemetry": {
          "ip": "203.0.113.1",
          "ipChain": ["203.0.113.1"],
          "userAgent": "Mozilla/5.0 ...",
          "geo": {
            "countryCode": "US",
            "region": "CA",
            "city": "Los Angeles",
            "latitude": 34.0522,
            "longitude": -118.2437,
            "accuracyRadiusKm": 20,
            "source": "maxmind"
          }
        },
        "customMetadata": { "leadId": "123" },
        "sessionId": "session_abc"
      }
    }
  }

─── POST /v1/cdrs/:cdrId/collect ───
Mark a CDR as collected (billing attribution). Idempotent — calling twice returns alreadyCollected: true.

  curl -X POST -H "X-API-Key: $EC_API_KEY" "$EC_API_BASE_URL/v1/cdrs/abc_123/collect"

Response:
  {
    "ok": true,
    "data": {
      "cdrId": "abc_123",
      "collected": true,
      "alreadyCollected": false
    }
  }

─── POST /v1/cdrs/:cdrId/share ───
Generate a share token for a CDR. The CDR does NOT need to be collected first.
Optional JSON body: { "expiresInMs": 3600000 } (default 30 days, max 2 years). Each call generates a new unique token.

  curl -X POST -H "X-API-Key: $EC_API_KEY" -H "Content-Type: application/json" \
    -d '{"expiresInMs":3600000}' "$EC_API_BASE_URL/v1/cdrs/abc_123/share"

Response:
  {
    "ok": true,
    "data": {
      "token": "share_token_xyz",
      "shareUrl": "/v1/shares/share_token_xyz",
      "expiresAt": 1739200000000
    }
  }

Note: When using autoShare in the SDK, the returned shareUrl is a full absolute URL. When using this API endpoint, prepend your base URL to the returned path.

─── POST /v1/shares/:token ───
Claim a shared CDR into your organization. Buyer uses their own API key.
Idempotent — claiming twice returns alreadyClaimed: true.
Cannot claim your own shared CDR (returns 400 INVALID_ARGUMENT).
Expired tokens return 410 GONE.
Legacy path /v1/shares/:token/claim also supported.

  curl -X POST -H "X-API-Key: $BUYER_API_KEY" "$EC_API_BASE_URL/v1/shares/share_token_xyz"

Response:
  {
    "ok": true,
    "data": {
      "claimed": true,
      "alreadyClaimed": false,
      "cdrId": "abc_123",
      "domainId": "example.com",
      "shareEventId": "se_abc123def456"
    }
  }

After claiming: CDR appears under the buyer's org in the dashboard. Buyer can view, download evidence image, or generate a PDF report.

COLLECTED vs PENDING:
- Pending: evidence saved but billing not recorded. downloadUrl is NOT returned.
- Collected: billing attribution exists. downloadUrl IS returned.
- Download URLs are short-lived signed URLs (~5 min). Always store cdrId, not the URL.
- Auto-collect is enabled by default — CDRs are automatically collected on creation. Contact support to disable if you need manual billing control.

────────────────────────────────────────
8. WEBHOOKS — FULL REFERENCE
────────────────────────────────────────

Webhooks notify your server when CDR processing completes (evidence image is ready). Eliminates need to poll the API.
Setup: configure webhook URL in dashboard Organization → Settings. Must be HTTPS in production.

EVENT: cdr.completed
Fires when CDR is fully processed and ready for download.

EXAMPLE PAYLOAD (POST with Content-Type: application/json):
  {
    "event": "cdr.completed",
    "orgId": "cid_your_org",
    "cdrId": "abc_123",
    "domainId": "example.com",
    "domain": "example.com",
    "createdAt": 1738600000000,
    "ip": "203.0.113.1",
    "userAgent": "Mozilla/5.0 ...",
    "downloadUrl": "https://storage.googleapis.com/... (short-lived)",
    "customMetadata": { "leadId": "123" },
    "sessionId": "session_abc",
    "subGroupIds": ["step-1"],
    "packageId": "pkg_abc",
    "webhookId": "wh_a1b2c3d4",
    "timestamp": 1738600005000
  }

FIELD TYPES:
- event: string — always "cdr.completed"
- orgId: string — your organization ID (CID)
- cdrId: string — the CDR identifier
- domainId: string — domain that produced the CDR
- domain: string — domain name
- createdAt: number — when CDR was created (epoch ms)
- ip: string | null — signer's IP address (best-effort)
- userAgent: string | null — signer's User-Agent (best-effort)
- downloadUrl: string — short-lived signed URL for evidence image (only when collected)
- customMetadata: object — custom metadata from captureCDR({ custom: ... })
- sessionId: string — session identifier
- subGroupIds: string[] — sub-group identifiers
- packageId: string — package identifier (session-level grouping)
- webhookId: string — unique delivery identifier (use for deduplication)
- timestamp: number — when webhook was sent (epoch ms)

SIGNATURE VERIFICATION (recommended for production):
Configure a signing secret in dashboard Organization → Settings.

Headers included when secret is set:
- X-EC-Signature: string — "sha256=<hex digest>"
- X-EC-Timestamp: string — Unix timestamp (seconds) when signed
- X-EC-Webhook-Id: string — unique delivery ID (always present even without secret)

Verification algorithm:
1. Extract X-EC-Signature, X-EC-Timestamp, and raw JSON body.
2. Reject if timestamp is older than 5 minutes (replay protection).
3. Construct signed message: "{timestamp}.{rawBody}" (timestamp + literal period + raw body).
4. Compute HMAC-SHA256 of that message using your signing secret.
5. Prefix hex digest with "sha256=".
6. Compare with X-EC-Signature using constant-time comparison.

Node.js verification example:

  import crypto from "node:crypto";
  const WEBHOOK_SECRET = process.env.EC_WEBHOOK_SECRET;
  const MAX_AGE_SECONDS = 300;

  function verifySignature(req) {
    const signature = req.headers["x-ec-signature"];
    const timestamp = req.headers["x-ec-timestamp"];
    const webhookId = req.headers["x-ec-webhook-id"];
    const rawBody = JSON.stringify(req.body);
    if (!signature || !timestamp || !webhookId) return { valid: false, reason: "Missing headers" };
    const age = Math.floor(Date.now() / 1000) - Number(timestamp);
    if (Number.isNaN(age) || age > MAX_AGE_SECONDS) return { valid: false, reason: "Timestamp too old" };
    const message = timestamp + "." + rawBody;
    const expected = "sha256=" + crypto.createHmac("sha256", WEBHOOK_SECRET).update(message).digest("hex");
    const a = Buffer.from(expected);
    const b = Buffer.from(signature);
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return { valid: false, reason: "Signature mismatch" };
    return { valid: true, webhookId };
  }

RETRIES:
- Max attempts: 5
- Backoff: 30 seconds to 10 minutes between retries (exponential)
- Timeout per attempt: 20 seconds
- Total retry window: up to 1 hour
- Once successfully delivered (2xx), will not be sent again.

BEST PRACTICES:
- Return 200 immediately. Process async (enqueue a job). Long handlers risk timeout + unnecessary retries.
- Deduplicate by webhookId — your handler must be safe to call twice with the same event.
- Store cdrId, NOT downloadUrl. URLs expire in minutes. Fetch fresh URL via GET /v1/cdrs/:cdrId.
- Match leads via customMetadata. Pass leadId/phone in captureCDR({ custom: { leadId: "..." } }) so the webhook payload has the fields you need.

MINIMAL EXPRESS HANDLER:

  app.post("/webhooks/expressconsent", express.json(), (req, res) => {
    const event = req.body;
    if (event.event === "cdr.completed") {
      console.log("CDR ready:", event.cdrId, event.customMetadata);
    }
    res.status(200).send("ok");
  });

────────────────────────────────────────
9. DISCLOSURE TRACKING
────────────────────────────────────────

Tag disclosure text in HTML with data-ec-disclosure="key", then pass agreement status in captureCDR() via the disclosures parameter.

HOW IT WORKS:
1. Tag disclosure text in HTML: <p data-ec-disclosure="sms">By submitting, you consent to receive SMS...</p>
2. Pass agreement status in captureCDR(): ExpressConsent.captureCDR({ disclosures: { "sms": true } })
3. The SDK extracts the tagged text from the DOM and pairs it with the boolean.

WHAT disclosures MEANS:
- disclosures is an array of entries with:
  - key: identifier from the data-ec-disclosure attribute value
  - language: extracted text from the DOM element (up to 5000 chars, HTML stripped, whitespace normalized)
  - agreed: the boolean passed in the disclosures parameter
- The disclosures section reflects what the CDR-generating integration reported. It is NOT an ExpressConsent attestation of the disclosure's existence, legality, or validity.
- Anyone reviewing the CDR can independently verify disclosures by examining the visual snapshot.

MULTIPLE DISCLOSURES:
  <p data-ec-disclosure="sms">SMS disclosure text...</p>
  <p data-ec-disclosure="email">Email disclosure text...</p>

  ExpressConsent.captureCDR({
    disclosures: {
      "sms": true,
      "email": emailCheckbox.checked,
    }
  })

VALIDATION:
- Non-boolean values in disclosures are skipped with a console warning. captureCDR() is never interrupted.
- Keys with no matching data-ec-disclosure element in the DOM are skipped.
- Tagged elements not mentioned in disclosures are not captured.
- Tag the full disclosure language (the legal text), NOT the consent expression ("I agree", button labels).

WHERE IT APPEARS:
- GET /v1/cdrs/:cdrId response (disclosures field)
- cdr.completed webhook payload (disclosures field)
- Dashboard CDR detail view (Disclosures section)

LEGACY (deprecated):
- Older SDK versions used data-ec-consent-checkbox and data-ec-consent-text attributes with automatic TCPA keyword detection (stored in consentLanguage field). This approach still works but is deprecated. New integrations should use data-ec-disclosure and the disclosures parameter.

────────────────────────────────────────
10. SNAPSHOT RENDERING FIDELITY
────────────────────────────────────────

ExpressConsent recreates your page from the captured DOM state to produce the visual record. Follow these rules so the snapshot is accurate and legally defensible:

CONSENT UI REQUIREMENTS:
- Use standard HTML form controls for checkboxes, radios, inputs. Custom-drawn controls (canvas, WebGL, SVG-only toggles) may not render correctly.
- Checkboxes must look checked — the checked state must produce a visible tick/highlight in HTML/CSS.
- Input values must be present at capture time. Some UI frameworks clear inputs on submit — if that happens, values won't appear in evidence.
- Consent/TCPA text must be real HTML text, not images. Text in images can't be verified programmatically.
- Show the full disclaimer. Don't hide behind "Read more" links or collapsed accordions. If text isn't visible at capture time, it won't appear in the snapshot.

THINGS THAT HURT FIDELITY:
- Cross-origin iframes: content appears blank. Move consent UI to the top-level page, or load SDK inside the iframe separately. Same-origin iframes generally work.
- Animations on consent elements: fade-ins, transitions, spinners on the checkbox/disclaimer can cause them to appear semi-transparent or missing. Keep consent elements static.
- Late-injected content: consent text loaded asynchronously (fetched from API on submit) may not be present at capture time. Render upfront.
- Lazy-loaded assets: don't use loading="lazy" on images/styles in the consent section.
- Overlays/spinners: if your submit handler shows a loading overlay immediately, it may cover the consent form. Capture BEFORE showing any overlay.
- Locally-served assets (localhost): images/fonts/styles from localhost aren't reachable by cloud rendering. Assets from public URLs (CDNs, Google Fonts) work fine. Use captureCDR({ inlineAssets: true }) for local dev.

RESPONSIVE DESIGN:
- Mobile must show full consent text. If mobile CSS hides/truncates the disclaimer (display: none on small screens), the snapshot reflects that.
- Expandable sections: ensure the consent section is expanded when user submits.

FONTS & STYLING:
- CSP must allow remote fonts/stylesheets.
- Cross-domain font files need CORS headers.
- Use crossorigin="anonymous" on cross-domain stylesheet <link> tags so styles can be captured.

What’s included

What’s not included

The prompt contains only public integration knowledge. It does not include your API keys, CID, organization details, or any account-specific configuration. You’ll still need to provide those to your AI when writing code for your specific integration.