# The cdr.completed event

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

Setup, retries, and delivery guarantees are on [set up webhooks](https://app.expressconsent.com/docs/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.

**`webhookId`** (`string`, required)

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`.

**`timestamp`** (`number`, required)

When this delivery attempt was assembled.

Constraints: 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

**`cdrId`** (`string`, required)

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

**`orgId`** (`string`, required)

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

**`domainId`** (`string`, required)

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

**`domain`** (`string`, required)

The domain recorded at capture.

## Timestamps

Both are Unix timestamps in milliseconds.

**`capturedAt`** (`number`, required)

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

**`createdAt`** (`number`, required)

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

**`downloadUrl`** (`string`, optional)

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](https://app.expressconsent.com/docs/concepts/access).

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.

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

**`invalidated`** (`boolean`, optional)

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

**`ip`** (`string | null`, optional)

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.

**`userAgent`** (`string | null`, optional)

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

> **Info: The payload carries less signer telemetry than the API does**
>
> These two fields are flat, and they are all there is. The forwarding chain and
> the approximate location are on the API's `signerTelemetry` object and are not
> sent here. Fetch the record if you need them.

## Consent

**`detectedDisclosureDetails`** (`object`, optional)

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](https://app.expressconsent.com/docs/reference/api/cdrs).

**`disclosures`** (`array`, optional)

**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](https://app.expressconsent.com/docs/migrate/verifiable-consent).

**`consentLanguage`** (`array`, optional)

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

## Your metadata

**`customMetadata`** (`object`, optional)

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.

**`autoMetadata`** (`object`, optional)

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](https://app.expressconsent.com/docs/reference/api/cdrs).

## Grouping

**`sessionId`** (`string`, optional)

The session this capture belonged to. See
[sessions and Package CDRs](https://app.expressconsent.com/docs/concepts/sessions).

**`packageId`** (`string`, optional)

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

- [Verify webhook signatures](https://app.expressconsent.com/docs/webhooks/signatures): Confirm a delivery came from us and not from someone who found your endpoint.
- [CDR endpoints](https://app.expressconsent.com/docs/reference/api/cdrs): List your domains, page through records, read one record in full, and collect a record.
