The cdr.completed event

The only event we send. One per completed record, POSTed as JSON to your webhook URL.

Setup, retries, and delivery guarantees are on set up webhooks.

json
{
  "event": "cdr.completed",
  "orgId": "org_abc123",
  "cdrId": "cdr_abc123",
  "domainId": "example.com",
  "domain": "example.com",
  "capturedAt": 1741234567890,
  "createdAt": 1741234571230,
  "ip": "203.0.113.4",
  "userAgent": "Mozilla/5.0 ...",
  "downloadUrl": "https://storage.googleapis.com/...",
  "customMetadata": { "phone": "555-0100", "leadId": "L-8823" },
  "sessionId": "sess_9f2a",
  "detectedDisclosureDetails": {
    "userSubmitted": true,
    "submitNodeId": 214,
    "disclosures": [
      {
        "key": "tcpa",
        "text": "By submitting this form you agree to be contacted at the number provided.",
        "checkboxDetected": true,
        "checkboxAffirmed": true,
        "consentMechanism": "checkbox"
      }
    ],
    "derivedAt": 1741234571180,
    "deriverVersion": "disclosure-details-deriver@3"
  },
  "webhookId": "6f9619ff-8b86-4fd1-b42f-7fd68a2c0f5a",
  "timestamp": 1741234571500
}

Optional fields are omitted rather than sent as null, except where a row below says otherwise. Do not assume a field is present because it was present last time.

Event and delivery

event"cdr.completed"required

The event type. This is the only value there is; branch on it anyway, so adding a second event does not break your handler.

webhookIdstringrequired

A unique identifier for this delivery attempt, matching the X-EC-Webhook-Id header.

It changes on every retry, so it is not an idempotency key. Deduplicate on cdrId.

timestampnumberrequired

When this delivery attempt was assembled.

Unix milliseconds. The X-EC-Timestamp header carries the same instant in seconds, and it is the seconds value the signature is computed over.

Identity

cdrIdstringrequired

The record identifier, the same value captureCDR() returned in the browser. This is the field to join on and the field to deduplicate on.

orgIdstringrequired

Your organization identifier, the same value you put in data-ec-cid. Useful when several organizations you operate point at one endpoint.

domainIdstringrequired

The base domain the record is filed under, and the value the API's list path takes.

domainstringrequired

The domain recorded at capture.

Timestamps

Both are Unix timestamps in milliseconds.

capturedAtnumberrequired

When the browser took the capture, the evidentiary moment. It comes from the visitor's device, so treat it as reported rather than authoritative.

createdAtnumberrequired

When we finished processing the record. Sort on this one if you need an order; deliveries arrive in no particular order.

Usually later than capturedAt, but not guaranteed: the two are equal on a record captured before we began recording client capture time, and a visitor's fast clock can invert them.

Evidence

downloadUrlstringoptional

A time-limited URL for the visual record.

Present when your organization can already download the record and the record has not been invalidated. See access and collection.

It can also be absent for neither of those reasons: minting the URL is best-effort, and a failure there sends the event without it rather than retrying. So absent never means the image is missing; ask the API for a fresh URL by cdrId.

Seven days is a working window, not storage. Store the cdrId and ask the API for a fresh URL.

Expires seven days after the delivery attempt was assembled, not after you received it. A URL fetched from the API lasts 300 seconds instead.
invalidatedbooleanoptional

Present, and true, only when we have flagged this record invalid. It is absent on a valid record rather than false.

The event is still delivered so you learn the record exists and must not be used or sold. downloadUrl is withheld.

Who submitted

ipstring | nulloptional

The consumer's IP address. null when it could not be determined.

ip and userAgent come from one telemetry record, so they are present together or absent together. Both are absent when no telemetry was recorded for the capture, or when we could not read it.

userAgentstring | nulloptional

The consumer's user agent string, present and absent under exactly the same conditions as ip.

detectedDisclosureDetailsobjectoptional

What we derived about consent at the detected moment of submission, read back out of the captured page rather than reported by your code. Absent entirely when no submission was detected, which is the strongest single signal that a record is weak evidence.

userSubmitted is always true; the object's presence is the signal that a submission was observed. submitNodeId identifies the control that was pressed, when we could pin its node. derivedAt and deriverVersion record when the facts were derived and by what, so they can be re-derived from the stored capture and compared.

disclosures holds one entry per disclosure found, up to 50. key, text, checkboxDetected, and consentMechanism are always on an entry; consentMechanism is one of checkbox, button_submission, or none_detected, and is worth reading in preference to the booleans. checkboxAffirmed and checkboxNodeId appear together and only when a readable checkbox bound to that disclosure, and checkboxAffirmed is true only when every readable checkbox bound to it was checked.

The array can be empty. A page with a detected submission and no readable disclosure yields the object with disclosures: [], which is what an untagged or legacy-tagged page produces. Test disclosures.length rather than the presence of the object. Full semantics are on CDR endpoints.

disclosuresarrayoptional

Deprecated. The { key, language, agreed } entries produced by the disclosures capture option: booleans your code asserted, not anything we observed. Present only when a page still passes that option. See migrate to derived consent.

consentLanguagearrayoptional

Deprecated. Disclosure text detected in the browser by pages that have not adopted data-ec-disclosure. Present only for those pages.

Your metadata

customMetadataobjectoptional

Exactly what you passed as custom on the capture call. This is where your own lead identifier comes back to you, and the reason to pass one.

autoMetadataobjectoptional

Fields we derived from the captured page, never anything you supplied: phones, firstNames, lastNames, pageTitle, formAction, and the UTM and click parameters from the page URL. Present only when something was found. Shapes and caps are on CDR endpoints.

Grouping

sessionIdstringoptional

The session this capture belonged to. See sessions and Package CDRs.

packageIdstringoptional

The package this record was filed into.

Unlike the API's field of the same name, this one is not a signal that the record is part of a multi-step flow. When it is present it names the record's session-derived package whether that package holds one record or ten. It is absent when the capture arrived carrying no package data, which is unusual but not something to assume away. The record is still filed into a package; this field has no name to report for it.

To find out whether a record has siblings, read packageId from the API instead, where it is present only for a package holding more than one record.

Next