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 the full ExpressConsent documentation: SDK setup, captureCDR() options and return values, verifiable consent, masking, sessions, sharing, the server-to-server API, webhooks, and tips for capturing high-quality evidence.

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 helps companies that generate or buy leads keep defensible proof of consent. When a consumer submits a form they consented on, you call captureCDR() and ExpressConsent saves a full-page screenshot of exactly what the consumer saw at that moment, sealed together with the signer's details and your own metadata into a Certified Digital Record (CDR).

A CDR contains: a full-page visual screenshot of exactly what the user saw, signer telemetry (IP address, User-Agent, geolocation) captured automatically from the upload request, timestamps, session info, any custom metadata you attach, and ExpressConsent-derived consent facts when a user submission is detected (see section 3). Each CDR is integrity-protected, stored for 5 years, accessible instantly in the dashboard or over the API, and shareable with lead buyers.

CORE TERMS:
- CDR (Certified Digital Record): one piece of evidence, identified by cdrId. The billable unit.
- Package CDR: a composite record bundling all CDRs from one browser session (a multi-step flow). Still billed per individual CDR.
- Collected vs pending: per-organization access/billing state for a CDR (see section 7).
- CID: your organization identifier, used by the SDK as data-ec-cid.

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

Add this script tag in <head>:

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

- Replace YOUR_CID with the CID from the dashboard (Organization Settings).
- The async attribute is recommended: it keeps the SDK off the page's critical render path (no render blocking) and the SDK still initializes well before any form submit, so captureCDR() is always ready in time. It also avoids a document.write fallback that some browsers block on slow connections. defer works too if you prefer ordered, never-parser-blocking execution.
- Do NOT self-host the SDK. It resolves its upload endpoint from the URL it was loaded from, so 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: await captureCDR() before the page navigates or the form submits. Awaiting keeps the page from navigating mid-capture, confirms the evidence was stored, and returns the cdrId. If the page navigates before the promise resolves, the CDR may not be saved.

CRITICAL: captureCDR() MUST be the first async call inside the submit handler. Do NOT await any other function before calling captureCDR(). The SDK ties the capture to the user-triggered submit event to verify that a real user action initiated the capture. Awaiting anything else first (e.g. a validation API call, a fetch, a delay) breaks that link: the browser's user-activation context expires, and the capture can no longer be associated with the legitimate user-triggered submission. Read synchronous values (e.g. form.phone.value) before the call if needed (that is fine), but the first await must be captureCDR().

Recommended pattern:

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    // Read any synchronous values you need BEFORE the first await.
    const phone = form.phone.value;
    try {
      // captureCDR() MUST be the first await. Do not await anything before this.
      // autoShare: true generates a share URL in the same call (no separate API request needed).
      const { cdrId, shareUrl } = await window.ExpressConsent.captureCDR({
        autoShare: true,
        custom: { phoneNumber: phone },
      });
      // Pass shareUrl to the lead buyer so they can access the evidence directly.
      // Only after captureCDR() resolves is it safe to await other things.
      await saveLead({ phone, cdrId, shareUrl });
    } catch (err) {
      // Log it and let the submit proceed. Never block submission on a capture failure.
      console.error("Capture failed:", err);
    }
    form.submit();
  });

OPTIONS (all optional):

  custom: object
    Arbitrary JSON metadata stored with the CDR. Max 16 KB. Every key becomes a queryable field: filter CDRs via the API using metadataKey + metadataValue query params (e.g. metadataKey=phoneNumber&metadataValue=+15551234567). Also visible in the dashboard and webhook payload as customMetadata. Include any fields you might want to look a CDR up by later: phone numbers, email addresses, campaign IDs, form names, your internal lead 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, the 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 fetching 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 it with your lead record.
    packageData: {                    // Always present. Session grouping info.
      packageId: string               // The session-level 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 a Unix timestamp in milliseconds.
  }

ERROR BEHAVIOR:
captureCDR() throws when it cannot guarantee the evidence was persisted:
- SDK not loaded (missing script tag or data-ec-cid attribute)
- Network failure or timeout
- CAPTURE_DISABLED: capture has been administratively disabled for this org (contact support)
Always wrap in try/catch, log the error, and let the form submit proceed. Do NOT block form submission on a capture failure.

────────────────────────────────────────
3. VERIFIABLE CONSENT (tag your page)
────────────────────────────────────────

You never assert a consent outcome. You tag your HTML with plain author attributes; ExpressConsent derives the consent facts server-side from the captured page at the moment a submission is detected, and certifies what is observable. It never over-claims: anything missing, ambiguous, or unreadable is reported "absent", never as consent given.

THE TAGS (already serialized into the captured DOM; the server reads them directly):
- data-ec-disclosure="<id>": a text container holding disclosure language (the verbatim text is captured). id is optional when the page has a single disclosure.
- data-ec-submit: the control the user submits with. It is both the submission signal AND the element outlined in the rendered evidence. Consent is reported only when ExpressConsent detects the user activate this control, and the outlined button in the record is exactly the one the user pressed. It is also important to be able to outline the element in the rendered evidence due to the fact that a snapshot is a single moment in time and without the ability to outline the element, the record would not be able to show the exact button the user pressed.
- data-ec-consent-checkbox: a consent checkbox. Resolves from a native <input type="checkbox">, an element with role="checkbox"/role="switch" (read via aria-checked), or a container wrapping exactly one such control. Anything else (or an ambiguous multi-control wrapper) is reported "absent", never guessed.
- data-ec-consent-for="<id>": which disclosure(s) a checkbox governs. OPTIONAL when the page has exactly one disclosure + one checkbox (auto-bind); REQUIRED when there are multiple.

THE SUBMISSION GATE: consent is only meaningful at a real user submission. Consent is reported ONLY when the user activates a tagged submit. If no user triggered submission is detected, the capture stands as a faithful snapshot and NO consent is asserted.

WHY TAG: tagging lets ExpressConsent enrich the CDR with stronger evidence: it reads whether the checkbox was checked, confirms the user submitted, and captures the disclosure text so the record is searchable. For lead generators selling leads, this also lets a buyer filter on what they require, such as a disclosure that names their company.

NO AGREEMENT FLAGS NEEDED, just capture on submit:
  <p data-ec-disclosure="sms">By submitting this form, you consent to receive automated SMS messages...</p>
  <input type="checkbox" id="smsConsent" data-ec-consent-checkbox />
  <button type="submit" data-ec-submit>Submit</button>

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    // Reading .checked is synchronous, safe to do before captureCDR().
    // You can inspect the checkbox yourself to decide whether to capture at all.
    // ExpressConsent also derives the checkbox state independently server-side,
    // but you are free to gate the capture on your own business logic.
    const consentChecked = document.getElementById("smsConsent").checked;
    if (consentChecked) {
      // captureCDR() MUST be the first await. Never await anything else before it.
      await window.ExpressConsent.captureCDR(); // detects the submit, reads checkbox state, derives consent
    }
    form.submit();
  });

MULTIPLE DISCLOSURES, bind each checkbox explicitly:
  <p data-ec-disclosure="sms">...</p>
  <input type="checkbox" data-ec-consent-checkbox data-ec-consent-for="sms" />
  <p data-ec-disclosure="email">...</p>
  <input type="checkbox" data-ec-consent-checkbox data-ec-consent-for="email" />

WHAT EXPRESSCONSENT DERIVES (per disclosure, only when a submission is detected). Each disclosure carries consentMechanism:
- consentMechanism: "checkbox": a bound consent checkbox existed and was checked at submission (affirmative opt-in).
- consentMechanism: "button_submission": no consent checkbox was bound to the disclosure; consent was expressed by submitting the form with an affirmative control (no separate checkbox).
- consentMechanism: "none_detected": no affirmative consent detected — a bound checkbox was unchecked/unreadable, a consent checkbox on the page failed to bind, or (on a no-checkbox disclosure) the user submitted with a negative/decline control such as a "No" or "Decline" button.

STORED SHAPE (detectedDisclosureDetails), appears in the dashboard CDR view, share page + PDF, GET /v1/cdrs/:cdrId, and the cdr.completed webhook. The top-level userSubmitted means the SDK observed the user submit on this captured screen (it is not an arbitrary point-in-time snapshot):
  {
    "detectedDisclosureDetails": {
      "userSubmitted": true,
      "disclosures": [
        { "key": "sms", "text": "By submitting this form, you consent...", "consentMechanism": "checkbox" }
      ]
    }
  }

TROUBLESHOOTING DETECTION: if nothing is outlined in the evidence, no submission was detected, usually because captureCDR() ran without the user activating a tagged submit, or the submit control is inside a cross-origin iframe (invisible to the SDK). The reliable fix is to tag the real submit control with data-ec-submit and call captureCDR() in response to its activation.

────────────────────────────────────────
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 and 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 record for multi-step consent flows. It is a grouping/presentation concept with no billing significance.

Call captureCDR() at each consent moment in the same tab (multiple forms, or a prior page whose info you want visible alongside the consent). Multiple captureCDR() calls in the same tab automatically share a session and combine into one Package CDR.

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 the SDK, calls captureCDR(), sends the share URL to the 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: { 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 a form on the lead generator's site.
2. Lead generator calls captureCDR({ autoShare: true }) and 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. 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: auto-collect at capture time, POSTing to a share URL (collects by default), or 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; 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 to their own downstream partner, that downstream partner's POST collects (and bills) for them. This prevents one payment from cascading indefinitely.
- 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 is not refunded. Org X can pass { "collect": false } if they only want visibility.
- Most lead-generation businesses prefer the buyer to pay. To enable that, disable auto-collect on the generator's org on their org settings page; captured CDRs then sit unpaid until a buyer collects them via 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 body to add the CDR to your org without billing yourself. It shows as pending; collect later via POST /v1/cdrs/:cdrId/collect. (If the sharer is already a payer, you get free access regardless of collect: false.)

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

Base URL: https://api-next.expressconsent.com
Auth header: X-API-Key: <keyId>.<secret>
Generate keys in the dashboard (Organization, API Keys). Secret shown only once. NEVER expose it 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 does not exist or is not 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"
        }
      ],
      "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" },
        "detectedDisclosureDetails": {
          "userSubmitted": true,
          "disclosures": [
            { "key": "tcpa", "text": "By submitting this form, you consent...", "consentMechanism": "checkbox" }
          ]
        },
        "sessionId": "session_abc"
      }
    }
  }

detectedDisclosureDetails (present when a submission was detected) holds the derived facts: userSubmitted, and per-disclosure consentMechanism ("checkbox" | "button_submission" | "none_detected"). See section 3. CDRs from older integrations instead carry a legacy "disclosures" array (key/language/agreed); read it for back-compatibility but do not produce it.

POST /v1/cdrs/:cdrId/collect
Pay for a CDR for your organization. After a successful collect, your org is billed and gains download access. Idempotent for your org (calling twice returns alreadyCollected: true). Multiple orgs can independently collect the same CDR.

  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.
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: SDK autoShare returns a full absolute shareUrl. This API endpoint returns a path; prepend your base URL.

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. If the org that created the share has already paid, you are NOT billed; free download access is granted instead.
Idempotent (POSTing twice from the same org returns alreadyClaimed: true; never bills twice).
Cannot open a share URL you generated yourself (returns 400 INVALID_ARGUMENT). Expired tokens return 410 GONE.

  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 } to add the CDR to your org without billing. It shows up but you cannot download until you collect later. If the sharer was already a payer, free access is granted regardless.

COLLECTED vs PENDING:
- The "collected" field is per-organization. Two orgs viewing the same CDR may see different collected values.
- Your org has collected: true if either (a) your org paid for it, or (b) your org received it via a share URL from an org that has already paid (free access).
- 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 auto-collected (billed) on creation. Disable it if you want buyers to pay instead.

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

Webhooks notify your server when CDR processing completes (the evidence image is ready), so you do not have to poll.
Setup: configure the webhook URL in the dashboard (Organization Settings). Must be HTTPS in production.

EVENT: cdr.completed (fires when a 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",
    "packageId": "pkg_abc",
    "detectedDisclosureDetails": {
      "userSubmitted": true,
      "disclosures": [
        { "key": "tcpa", "text": "By submitting this form, you consent...", "consentMechanism": "checkbox" }
      ]
    },
    "webhookId": "wh_a1b2c3d4",
    "timestamp": 1738600005000
  }

FIELD TYPES:
- event: string, always "cdr.completed"
- orgId: string, your organization ID (CID)
- cdrId: string, the CDR identifier
- domainId / domain: string, the domain that produced the CDR
- createdAt: number, when the CDR was created (epoch ms)
- ip: string | null, signer's IP (best-effort)
- userAgent: string | null, signer's User-Agent (best-effort)
- downloadUrl: string, short-lived signed URL for the evidence image. Present only when YOUR org currently has download access.
- customMetadata: object, the metadata from captureCDR({ custom: ... })
- sessionId: string, session identifier
- packageId: string, package identifier (session-level grouping)
- detectedDisclosureDetails: object, derived facts (present when a submission was detected): userSubmitted, and a disclosures[] array of { key, text, consentMechanism } where consentMechanism is "checkbox" | "button_submission" | "none_detected". Legacy CDRs instead carry a "disclosures" array of { key, language, agreed }.
- webhookId: string, unique delivery identifier (use for deduplication)
- timestamp: number, when the webhook was sent (epoch ms)

SIGNATURE VERIFICATION (recommended for production):
Configure a signing secret in the dashboard (Organization Settings).
Headers when a secret is set:
- X-EC-Signature: "sha256=<hex digest>"
- X-EC-Timestamp: Unix timestamp (seconds) when signed
- X-EC-Webhook-Id: unique delivery ID (always present, even without a secret)

Verification algorithm:
1. Extract X-EC-Signature, X-EC-Timestamp, and the raw JSON body.
2. Reject if the timestamp is older than 5 minutes (replay protection).
3. Construct the signed message: "{timestamp}.{rawBody}" (timestamp + literal period + raw body).
4. Compute HMAC-SHA256 of that message using your signing secret.
5. Prefix the hex digest with "sha256=".
6. Compare with X-EC-Signature using a 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), it will not be sent again.

BEST PRACTICES:
- Return 200 immediately and process async (enqueue a job). Long handlers risk timeouts and extra 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 a fresh URL via GET /v1/cdrs/:cdrId.
- Match leads via customMetadata; pass searchable fields in captureCDR({ custom: { ... } }).

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. CAPTURING HIGH-QUALITY EVIDENCE
────────────────────────────────────────

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

CONSENT UI REQUIREMENTS:
- Use standard HTML form controls for checkboxes, radios, and 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 frameworks clear inputs on submit; if that happens, values will not appear in evidence.
- Consent text must be real HTML text, not images. Text in images cannot be verified programmatically.
- Show the full disclosure. Do not hide it behind "Read more" links or collapsed accordions. If text is not visible at capture time, it will not appear in the snapshot.

THINGS THAT HURT FIDELITY:
- Cross-origin iframes: content appears blank. Move consent UI to the top-level page, or load the SDK inside the iframe separately. Same-origin iframes generally work.
- Animations on consent elements: fade-ins, transitions, and spinners can make the checkbox/disclosure appear semi-transparent or missing. Keep consent elements static.
- Late-injected content: consent text fetched asynchronously on submit may not be present at capture time. Render it upfront.
- Lazy-loaded assets: do not 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 served from localhost are not reachable by cloud rendering and will appear broken or missing. Assets from public URLs (CDNs, Google Fonts) work fine. For local dev, use captureCDR({ inlineAssets: true }).

RESPONSIVE DESIGN:
- Mobile must show the full consent text. Run at least one mobile-sized test submission. If mobile CSS hides or truncates the disclosure (display: none on small screens), the snapshot reflects that.
- Expandable sections: ensure the consent section is expanded when the user submits.

FONTS & STYLING:
- Your 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.