# CDR endpoints

Read the records your organization holds, and pay for the ones you want to download.

Authentication, the response envelope, and pagination are on
[API conventions](https://app.expressconsent.com/docs/reference/api). All paths are relative to
`https://api-next.expressconsent.com`.

| | |
|---|---|
| `GET /v1/domains` | Every domain you have records on |
| `GET /v1/domains/:domainId/cdrs` | Page through the records on one domain |
| `GET /v1/cdrs/:cdrId` | One record in full |
| `POST /v1/cdrs/:cdrId/collect` | Pay for a record and gain download access |

## GET /v1/domains

Lists the domains we have seen captures on for your organization, sorted alphabetically. There is no
pagination; you get all of them. A domain appears as soon as a capture is uploaded from it, slightly
before the first record on it becomes readable, so a newly added domain can list with no records yet.

```bash
curl -sS -H "X-API-Key: $EC_API_KEY" \
  "https://api-next.expressconsent.com/v1/domains"
```

```json
{
  "ok": true,
  "data": { "domains": ["example.com", "other-example.com"] },
  "requestId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301"
}
```

A domain identifier is the last two labels of the capture hostname, so captures from `app.example.com`
are filed under `example.com`.

Records shared into your account add their originating domain to this list.

## GET /v1/domains/:domainId/cdrs

Pages through the records on one domain, newest first.

```bash
curl -sS -H "X-API-Key: $EC_API_KEY" \
  "https://api-next.expressconsent.com/v1/domains/example.com/cdrs?pageSize=50"
```

### Query parameters

`pageSize`, `pageToken`, and `order` behave as described in
[API conventions](https://app.expressconsent.com/docs/reference/api).

**`metadataKey`** (`string`, optional)

Return only records carrying this key in `customMetadata` or `autoMetadata`. Must be sent together
with `metadataValue`; on its own it is a `400 INVALID_ARGUMENT`.

**`metadataValue`** (`string`, optional)

The value to match. Matching is exact, but case-insensitive and whitespace-normalized. On its own,
without `metadataKey`, it is ignored.

This is how you find a record from a phone number or your own lead identifier eighteen months later.
Only one pair can be filtered at a time, and only values that were indexed are findable. See `custom`
on [captureCDR()](https://app.expressconsent.com/docs/reference/capture-cdr) for what gets indexed.

> **Info: Filtering by metadata drops the sort order**
>
> A request with `metadataKey` ignores `order` and returns matches in an unspecified order. Sort them
> yourself on `createdAt` if the order matters. Paging still works.

### Response

```json
{
  "ok": true,
  "data": {
    "cdrs": [
      {
        "cdrId": "cdr_abc123",
        "domainId": "example.com",
        "domain": "example.com",
        "createdAt": 1741234571230,
        "contentType": "image/jpeg",
        "size": 512000,
        "collected": true
      }
    ],
    "nextPageToken": "cdr_abc123"
  },
  "requestId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301"
}
```

A list entry is a subset of [the record shape](#the-record-shape): `cdrId`, `domainId`, `domain`,
`createdAt`, `contentType`, `size`, `collected`, `downloadUrl`, `customMetadata`, `autoMetadata`,
`sessionId`, `packageId`, `webhookSuccessfullySentAt`, `pdfShareId`, and `pdfCreatedAt`.
It does not carry `organizationName`, `capturedAt`, `guest`, `producerOrgId`, or any of the page-context
or consent fields; fetch a single record for those.

A page can contain fewer records than you asked for and still not be the last page, so follow
`nextPageToken` rather than counting.

## GET /v1/cdrs/:cdrId

Returns one record with everything known about it. The record must belong to your organization, either
because it was captured on your domain or because it was shared into your account.

```bash
curl -sS -H "X-API-Key: $EC_API_KEY" \
  "https://api-next.expressconsent.com/v1/cdrs/cdr_abc123"
```

```json
{
  "ok": true,
  "data": {
    "cdr": {
      "cdrId": "cdr_abc123",
      "domainId": "example.com",
      "domain": "example.com",
      "organizationName": "Example Leads",
      "capturedAt": 1741234567890,
      "createdAt": 1741234571230,
      "contentType": "image/jpeg",
      "size": 512000,
      "collected": true,
      "downloadUrl": "https://storage.googleapis.com/...",
      "signerTelemetry": { "ip": "203.0.113.4", "ipChain": ["203.0.113.4"], "userAgent": "Mozilla/5.0 ..." },
      "detectedDisclosureDetails": {
        "userSubmitted": true,
        "disclosures": [
          {
            "key": "tcpa",
            "text": "By submitting this form you agree to be contacted at the number provided.",
            "checkboxDetected": true,
            "checkboxAffirmed": true,
            "consentMechanism": "checkbox"
          }
        ]
      }
    }
  },
  "requestId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301"
}
```

## The record shape

Every field below is on the single-record response. List entries carry the subset named earlier.

### Identity

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

The record identifier, the same value `captureCDR()` returned.

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

The base domain the record is filed under, and the value to use in the list path.

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

The domain recorded at capture.

**`organizationName`** (`string`, required)

The name of your organization, as a convenience for rendering. Falls back to `Organization` when it
cannot be read.

### Timestamps

Both are Unix timestamps in milliseconds.

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

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

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

When we finished processing the record on our side. This is the field `order` sorts on and the one a
record's age is measured from.

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

### Evidence image

**`contentType`** (`string`, required)

The image's MIME type.

**`size`** (`number`, required)

The image's size in bytes.

**`collected`** (`boolean`, required)

Whether your organization has download access to this record. Two organizations asking about the
same record legitimately get different answers. See
[access and collection](https://app.expressconsent.com/docs/concepts/access).

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

A time-limited URL for the visual record, present only when `collected` is `true`. Do not store
it; store the `cdrId` and request a fresh URL when you need one.

Constraints: Expires 300 seconds after the response is generated.

### Page context

**`pageUrl`** (`string`, optional)

The URL of the captured page, including its query string. If you put personal data in query
parameters, it is here.

**`clientTimezone`** (`string`, optional)

The visitor's IANA time zone, as reported by their browser.

**`timeOnPageMs`** (`number`, optional)

How long the person had the page open before submitting. A useful signal for arguing a disclosure
was readable rather than instantly dismissed.

**`signerTelemetry`** (`object`, optional)

Who submitted, as far as the network can say: `ip` and `userAgent` (either may be `null`),
`ipChain` listing every address in the forwarding chain, and `geo` with an approximate location
derived from the address. Best-effort: the whole object is absent when it could not be recorded.

### Consent

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

What we derived about consent at the detected moment of submission. **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, so test for the object rather than for this field. `submitNodeId` identifies the control
that was pressed. `derivedAt` and `deriverVersion` record when the facts were derived and by which
deriver, so a record can be re-derived from the stored capture and compared.

`disclosures` holds one entry per disclosure found, up to 50, each with:

- `key`: the `data-ec-disclosure` value, or a generated one
- `text`: the disclosure language read from the page, whitespace-collapsed and truncated at 5,000 characters
- `checkboxDetected`: whether a consent checkbox was bound to it and readable
- `checkboxAffirmed`: whether *every* readable checkbox bound to it was checked; present only when `checkboxDetected` is `true`
- `checkboxNodeId`: the first bound checkbox element, for audit
- `consentMechanism`: `checkbox`, `button_submission`, or `none_detected`

Read `consentMechanism` in preference to the booleans. It is absent only on records derived before
the field existed.

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

**Deprecated.** The booleans an integrator asserted through the `disclosures` option, rather than
anything we observed. Present only when that option was passed.

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

**Deprecated.** Disclosure text captured by SDK versions predating derived detection.

### Your metadata

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

Exactly what you passed as `custom` on the capture call.

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

Fields we derived from the captured page, never anything you supplied. Present only when something
was found, and filterable exactly like `customMetadata`.

`phones`, `firstNames`, and `lastNames` are sorted arrays read from identified form fields, with
`phones` also picking up numbers found in page text and normalized to `+1` form rather than stored
as written. `pageTitle` and `formAction` come from the page itself. `utmSource`, `utmMedium`,
`utmCampaign`, `utmTerm`, `utmContent`, `gclid`, `fbclid`, and `msclkid` come from the page URL's
query string.

Constraints: Keys up to 100 characters, 4,000 characters serialized in total. At most 10 phones and 5 of each name. pageTitle is cut at 200 characters, formAction at 500, and values from the URL at 200.

### Grouping and provenance

**`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 CDR this record belongs to, when it belongs to one.

**Present only when the package groups more than one record.** Every capture is filed into a
session-derived package, so a package holding a single record tells you nothing and is omitted. Its
presence means this record is part of a multi-step or co-registration flow whose records only tell
the full story together. See [sessions and Package CDRs](https://app.expressconsent.com/docs/concepts/sessions).

**`guest`** (`boolean`, optional)

`true` when this record reached your account by being shared with you rather than captured on your
own site.

**`producerOrgId`** (`string`, optional)

The organization that originally captured the record. Present on shared records, and unchanged by
further re-sharing, so it identifies the source rather than whoever passed it to you.

### Delivery status

**`webhookSuccessfullySentAt`** (`number`, optional)

When your webhook acknowledged this record. Absent means it has not been delivered, either not
yet or not successfully.

`pdfShareId` and `pdfCreatedAt` are in the response type but nothing populates them: a PDF evidence
report is generated against a dashboard share link and recorded there, not on the record. Do not
branch on either field.

## POST /v1/cdrs/:cdrId/collect

Pays for a record and grants your organization download access to it. This is a billable call. It takes
no request body.

```bash
curl -sS -X POST -H "X-API-Key: $EC_API_KEY" \
  "https://api-next.expressconsent.com/v1/cdrs/cdr_abc123/collect"
```

```json
{
  "ok": true,
  "data": { "cdrId": "cdr_abc123", "collected": true, "alreadyCollected": false },
  "requestId": "3f2504e0-4f89-11d3-9a0c-0305e82c3301"
}
```

Calling it again for the same record is safe and does not charge you twice. Most organizations never
call it: an organization with billing set up and auto-collect left on (the default) has captures on
its own domains collected automatically, and receiving a shared record collects it in the same request.
It exists for the case where you deliberately did not pay earlier and now want the evidence. See
[access and collection](https://app.expressconsent.com/docs/concepts/access).

**`collected`** (`boolean`, required)

Always `true`. The call either succeeds or returns an error.

**`alreadyCollected`** (`boolean`, required)

Whether your organization could already download this record before the call. `true` means the call
changed nothing and you were not charged again.

It answers only that question. It does not tell you *how* you got access: paying for it earlier and
receiving it free from an organization that had already paid both report `true`.

Organizations without billing set up cannot collect. Such an organization normally has no API access
either, so in practice every request from it returns `403 API_ACCESS_DISABLED` and never reaches this
endpoint; if API access was granted to it explicitly, the call returns `403
PASSTHROUGH_ORG_CANNOT_COLLECT` instead. That is the passthrough organization state, and changing it
means talking to us.

## Errors

Every code, its HTTP status, and what to do about it is on
[API errors](https://app.expressconsent.com/docs/reference/api/errors). The ones specific to these endpoints:

| Status | Code | Cause |
|---|---|---|
| 400 | `INVALID_ARGUMENT` | `metadataKey` without `metadataValue`, a `metadataKey` and `metadataValue` too long to index, or a `pageToken` that is not a record on this domain |
| 403 | `PASSTHROUGH_ORG_CANNOT_COLLECT` | Your organization has no billing set up |
| 404 | `NOT_FOUND` | No such record for your organization, or a record that has not finished rendering. On collect, also an organization record we could not read, so a `404` there is not always about the identifier in the path. |
| 410 | `CDR_INVALID` | The record was flagged invalid and cannot be read, collected, or shared |

A record flagged invalid is also dropped from list results rather than returned with a flag, so a
record that vanishes from a listing and answers `410` on a direct read has been invalidated. The
response type carries `invalidated`, `invalidatedAt`, and `invalidatedReason`, but no path through the
API returns them: an invalid record is filtered from lists and refused on a direct read, so those three
fields are unreachable.
