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();
    const uid = crypto.randomUUID(); // unique ID for this submission
    try {
      const { cdrId } = await window.ExpressConsent.captureCDR({
        custom: { uid, phoneNumber: "+15551234567" },
      });
      // Happy path: save cdrId with your lead record.
      console.log("Evidence saved:", cdrId);
    } catch (err) {
      // Upload failed — SDK saved CDR locally for automatic retry.
      // Save your lead without a cdrId. Reconcile later via API or webhook using the uid.
      console.error("Capture failed:", err);
    }
    e.currentTarget.submit();
  });

OPTIONS (all optional):

  custom: object
    Arbitrary JSON metadata stored with the CDR. Max 16 KB. Every key becomes a queryable field — you can filter CDRs via the API using metadataKey + metadataValue query params (e.g. metadataKey=phoneNumber&metadataValue=+15551234567). Also visible in dashboard + webhook payload as customMetadata. Include any fields you might want to look up CDRs by later: phone numbers, email addresses, campaign IDs, form names, etc.
    IMPORTANT: Always include a unique identifier from your system (e.g. lead ID, form-submission ID). This is the only way to match a CDR back to its originating record if the initial upload fails and the SDK retries later. See "OFFLINE RESILIENCE" section below.

  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.

OFFLINE RESILIENCE (automatic retry):
The SDK saves the captured CDR data to the browser's IndexedDB BEFORE attempting the upload. If the upload fails (slow connection, transient error, user navigating away), the data persists locally and the SDK retries automatically on the user's next page load on that site.

Key behaviors:
- captureCDR() only returns a cdrId when the evidence is confirmed persisted in ExpressConsent's storage. If the upload fails, captureCDR() throws. A cdrId is a guarantee that the evidence exists.
- Retry-uploaded CDRs appear in the dashboard, API, and webhook like any other CDR. There is no callback to the original page — the page that called captureCDR() is long gone.

RECONCILIATION PATTERN:
When captureCDR() fails, your lead record has no cdrId. To find the CDR after it uploads via retry:
1. Pass a unique ID in custom metadata: captureCDR({ custom: { uid: "your-lead-id" } })
2. Find the CDR via API: GET /v1/domains/:domainId/cdrs?metadataKey=uid&metadataValue=your-lead-id
3. Or receive it via webhook: the cdr.completed payload includes customMetadata with your uid. Match it to the lead record missing a cdrId and backfill.

Example:

  async function handleSubmit(formData) {
    const leadId = generateUniqueId();
    try {
      const { cdrId } = await window.ExpressConsent.captureCDR({
        custom: { uid: leadId, form: "signup" },
      });
      await saveLead({ ...formData, leadId, cdrId });
    } catch (err) {
      // SDK saved CDR locally — will retry on next page load.
      await saveLead({ ...formData, leadId, cdrId: null });
    }
  }

  // Backend: periodically query for leads missing cdrId
  // GET /v1/domains/example.com/cdrs?metadataKey=uid&metadataValue={leadId}
  // Or handle via webhook: match customMetadata.uid to backfill cdrId.

────────────────────────────────────────
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 (recipient): purchases leads, POSTs to the share URL with their own API key to receive the evidence.

OPTION 1 — Auto-share at capture time (recommended):
  const result = await window.ExpressConsent.captureCDR({
    autoShare: true,
    custom: { uid: "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 POSTs to the shareUrl with their X-API-Key header. This single call adds the CDR to the buyer's org AND collects it (one step). If the lead generator has already paid, the buyer is granted free download access instead of being billed.

BILLING & ACCESS (per organization, per CDR):
- Each org has independent access and independent billing. Multiple orgs can independently pay for the same CDR.
- Two ways an org gets download access to a CDR:
  1. The org pays for it — via auto-collect at capture time, by POSTing to a share URL (which collects by default), or via POST /v1/cdrs/:cdrId/collect.
  2. The org receives it via a share URL from an org that has already paid — free download access is granted automatically with no billing.
- Pay once, share with as many partners as you want. A single payment lets the payer hand the share URL to any number of buyers/partners; every direct recipient gets free download access on their POST.
- Free access flows ONE HOP forward from a payer. If a recipient who got free access then re-shares the CDR to their own downstream partner, that downstream partner's POST collects (and bills) for them, just like any normal recipient. This prevents one payment from cascading indefinitely down an unbounded chain of re-shares.
- Payment is NOT retroactive: if Org X opens a share URL before Org Y (the sharer) pays, Org X is billed at that moment. If Org Y later pays, Org X does not get a refund. Org X can opt out of the auto-collect by passing { "collect": false } in the body if they only want visibility.
- Most lead-generation businesses prefer the buyer to pay. To enable that flow, contact ExpressConsent support to disable auto-collect on the lead generator's org. Then captured CDRs sit unpaid in the generator's account, and the buyer becomes the payer when they POST to the share URL.

OPT OUT OF AUTO-COLLECT WHEN OPENING A SHARE URL:
- Default behavior on POST /v1/shares/:token is to collect for the recipient.
- Pass { "collect": false } in the request body to add the CDR to your org without billing yourself. The CDR will show as pending in your account; you can collect later via POST /v1/cdrs/:cdrId/collect. (If the sharer is already a payer, you get free access regardless of collect: false.)

CO-REGISTRATION & MULTIPLE BUYERS: Each buyer POSTs to their own share URL independently. If the generator hasn't paid, each buyer is billed independently when they collect. 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)
- CDR_INVALID (410): CDR has been administratively invalidated; treat like NOT_FOUND for evidence purposes
- 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 ───
Pay for a CDR for your organization. After a successful collect, your org is billed for this CDR and gains download access. Idempotent for your org — calling twice returns alreadyCollected: true. Multiple orgs can independently collect the same CDR; each pays their own bill and gets their own access.

  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 URL for a CDR. The CDR does NOT need to be collected first — either you or the recipient can pay. If you have already paid, recipients who POST to the share URL get free download access (no billing).
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 ───
Open a share URL with your org's API key. By default this is a single-step collect: the CDR is added to your org and your org is billed for it. If the org that created the share has already paid for the CDR, you are NOT billed — free download access is granted instead.
Idempotent — POSTing twice from the same org returns alreadyClaimed: true on the second call (never bills twice).
Cannot open a share URL you generated yourself (returns 400 INVALID_ARGUMENT).
Expired tokens return 410 GONE.
Legacy path /v1/shares/:token/claim also supported (behaves identically).

  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"
    }
  }

OPT OUT (save without billing):
Pass { "collect": false } in the body to add the CDR to your org without billing yourself. The CDR shows up in your account but you cannot download until you collect later via POST /v1/cdrs/:cdrId/collect. If the sharer was already a payer, free access is still granted regardless of collect: false.

  curl -X POST -H "X-API-Key: $BUYER_API_KEY" -H "Content-Type: application/json" \
    -d '{"collect": false}' \
    "$EC_API_BASE_URL/v1/shares/share_token_xyz"

After a successful POST: CDR appears in the buyer's dashboard. Buyer can view, download evidence image (if collected: true), or generate a PDF report.

COLLECTED vs PENDING:
- The "collected" field is per-organization — it reflects whether YOUR org has download access. Two orgs viewing the same CDR may see different collected values.
- Your org has collected: true for a CDR if either: (a) your org paid for it (auto-collect, share-URL POST default behavior, or POST /v1/cdrs/:cdrId/collect), OR (b) your org received it via a share URL from an org that has already paid (free access; no billing).
- downloadUrl is only returned when collected === true for your org.
- Download URLs are short-lived signed URLs (~5 min). Always store cdrId, not the URL.
- Auto-collect is enabled by default — CDRs your org captures via the SDK are automatically collected (your org is billed) on creation. Disable if you want lead buyers to pay instead.
- A single payment from one org enables that org to share with any number of partners; every direct partner gets free access. Free access flows one hop forward; downstream re-shares (partners-of-partners) collect on their own POST.

────────────────────────────────────────
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 present when YOUR org (the org receiving the webhook) currently has download access — auto-collect on, manual collect, or a one-hop free-access grant from a payer.
- 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 a unique ID in captureCDR({ custom: { uid: "..." } }) so the webhook payload has the fields you need to match the CDR back to your lead record.
- Reconcile retry-uploaded CDRs. When the SDK's initial upload fails, it retries on the user's next page load. These CDRs arrive without a cdrId in your lead record. Use the webhook to catch them: match customMetadata to find lead records missing a cdrId and backfill. See OFFLINE RESILIENCE in section 2.

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.