> ## Documentation Index
> Fetch the complete documentation index at: https://docs.casa-layer.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Creating a table of guests who have provided marketing consent

> Create a dynamic table for marketing email consent, then read members and validate each row.

A **table** is a group-owned list of guests you keep for operations work.
It is not a marketing segment. Campaign audiences live in a downstream ESP.

Each table has a fixed grain and a type:

| Field         | Values                | Meaning                                                         |
| ------------- | --------------------- | --------------------------------------------------------------- |
| `member_type` | `guest` or `master`   | Guest profiles, or master profiles. Set at create. Never mixed. |
| `type`        | `static` or `dynamic` | Hand-picked members, or members from a saved filter.            |

Use a **dynamic** table for this recipe. Save a filter on
`marketing_email_consent`. Casa Layer then keeps members whose effective
marketing-email status is `granted`.

Partner credentials are read-only. An operator creates the table in the
Casa Layer app. The partner then reads members over the public API.

## What you need

An operator with access to the Casa Layer app creates and saves the table.

The partner credential needs these scopes:

| Scope           | Why you need it                                         |
| --------------- | ------------------------------------------------------- |
| `read:tables`   | Table list, detail, members, filters, preview, and runs |
| `read:consents` | Optional extra check on stored consent observations     |
| `read:guests`   | Optional load of a master and its member guests         |

`write:tables` is session-only. Partner credentials cannot create or save a
table.

## What a table is

A table holds members in one property group. Member rows are directory
projections: name, email, phone, and when the member entered the table.

Each member also returns:

| Field               | Meaning                                                             |
| ------------------- | ------------------------------------------------------------------- |
| `id`                | Membership key                                                      |
| `guest_profile_id`  | Casa Layer guest profile ID, or `null` on a clustered master        |
| `master_profile_id` | Casa Layer master profile ID when one exists                        |
| `consents`          | Effective status per `contact_method` and `purpose`                 |
| `kind`              | `clustered` or `singleton` on a master table. Not the table `type`. |

On a **guest** table, `id` and `guest_profile_id` are the same. Use
`guest_profile_id` on other endpoints.

On a **master** table:

* A clustered row has `master_profile_id` set and `guest_profile_id` null.
  Load member guests from `GET /v1/master-profiles/{id}`.
* A singleton row is an unclustered guest. `guest_profile_id` is set.
  `master_profile_id` is null.

`consents` is the effective rollup. When more than one observation exists
for the same contact method and purpose, Casa Layer keeps the most
restrictive status: `withdrawn`, then `denied`, then `granted`.

No consent row means **unknown**. Unknown is not granted. A guest with an
empty `consents` array has not provided marketing consent.

See [Tables](/data-model#tables) and [Consents](/data-model#consents).

## 1. Create the table in the app

Do this in the Casa Layer app. Do not call create from a partner
credential.

1. Open **Tables**.
2. Click **Create table**.
3. Enter a name, for example `Marketing email granted`.
4. Set **Type** to **Dynamic**.
5. Set **Guest profile type**:
   * **Guest profiles** — one row per source record
   * **Master profiles (default)** — one row per resolved guest
6. Click **Create table**.

Type and guest profile type cannot change after create.

For most partner reads, pick **Guest profiles**. Each member then has a
`guest_profile_id` you can send to other endpoints.

Pick **Master profiles** when the partner wants one row per real guest.

## 2. Save the marketing consent filter

The new table opens with no members. Add the filter, then save it.

1. Click **Filter**.
2. Set **Field** to **Marketing email consent**.
3. Set **Condition** to **is**.
4. Set **Value** to **Granted**.
5. Click **Save**.

Save writes the filter and refreshes membership. Wait until the run
finishes. Then members are the current match set.

The saved filter is this document:

```json theme={null}
{
  "version": 1,
  "root": {
    "op": "and",
    "children": [
      {
        "field": "marketing_email_consent",
        "operator": "is",
        "value": "granted"
      }
    ]
  }
}
```

`marketing_email_consent` is the effective marketing-email status for the
member. Allowed values are `granted`, `denied`, and `withdrawn`.

A static table does not apply this filter. If you add members by hand,
validate `consents` on every row. Do not trust membership alone.

<Warning>
  Tables are not marketing segments. A destination still needs its own
  consent check. Membership is an operations list, not a send licence.
</Warning>

## 3. Create partner credentials

Create the credential in the Casa Layer app. See
[Partner API credentials](/partner-credentials) for the full steps.

1. Open **Integrations → Partner credentials → Custom Integration**.
2. Name the credential.
3. Select `read:tables`. Add `read:consents` and `read:guests` if the
   partner will run the extra checks below.
4. Click **Create credentials**.
5. Copy the **client ID** and **client secret**.

Casa Layer shows the client secret once. Store it with the partner.

## 4. Get an access token

Exchange the client ID and client secret for a short-lived access token.

```bash theme={null}
curl -X POST https://temperate-milestone-52.authkit.app/oauth2/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=client_credentials
```

Read `access_token` from the response. Cache it until `exp`.

Send the token as a bearer credential on every request below:

```bash theme={null}
-H "Authorization: Bearer $ACCESS_TOKEN"
```

## 5. Find the table

```bash theme={null}
curl "https://api.casa-layer.com/v1/tables?status=active" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

Table list uses [offset pages](/pagination#offset-pages-tables). Default
`limit` is 100. The maximum is 500.

Match the table by `name`, or store `id` when the operator creates it.

```bash theme={null}
curl https://api.casa-layer.com/v1/tables/3fa85f64-5717-4562-b3fc-2c963f66afa6 \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

Confirm `type` is `dynamic` and `member_type` is the grain you chose.

Read the saved filter:

```bash theme={null}
curl https://api.casa-layer.com/v1/tables/3fa85f64-5717-4562-b3fc-2c963f66afa6/filters \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

Confirm `filter_doc` includes `marketing_email_consent` with value
`granted`.

You can also preview a filter without a saved table:

```bash theme={null}
curl -X POST https://api.casa-layer.com/v1/tables/preview \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "member_type": "guest",
    "filter_doc": {
      "version": 1,
      "root": {
        "op": "and",
        "children": [
          {
            "field": "marketing_email_consent",
            "operator": "is",
            "value": "granted"
          }
        ]
      }
    }
  }'
```

The preview returns a `count`. It does not return members. Use it to check
the filter before the operator saves.

## 6. Retrieve members

```bash theme={null}
curl "https://api.casa-layer.com/v1/tables/3fa85f64-5717-4562-b3fc-2c963f66afa6/members?limit=100&offset=0" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

Member list uses offset pages. When `has_more` is `true`, send `offset`
equal to `next_offset`. Repeat until `has_more` is `false`.

```json theme={null}
{
  "data": [
    {
      "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
      "guest_profile_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
      "master_profile_id": null,
      "first_name": "Ada",
      "surname": "Lovelace",
      "email_address": "ada@example.com",
      "phone_number": null,
      "entered_at": "2026-03-01T10:15:02Z",
      "kind": null,
      "consents": [
        {
          "contact_method": "email",
          "purpose": "marketing",
          "status": "granted"
        }
      ]
    }
  ],
  "has_more": false,
  "next_offset": null
}
```

On a clustered master member the same page looks like this:

```json theme={null}
{
  "id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "guest_profile_id": null,
  "master_profile_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "kind": "clustered",
  "consents": [
    {
      "contact_method": "email",
      "purpose": "marketing",
      "status": "granted"
    }
  ]
}
```

A property-pinned personal API key narrows visible members. Partner
credentials are group-scoped. They see every member in the group.

## 7. Validate marketing consent

Treat each member as granted only when `consents` contains a row that
matches all three fields:

| Field            | Required value |
| ---------------- | -------------- |
| `purpose`        | `marketing`    |
| `contact_method` | `email`        |
| `status`         | `granted`      |

Reject the member when:

* `consents` is missing or empty
* the marketing-email row is `denied` or `withdrawn`
* the only granted row is a different purpose or contact method

Email and SMS are separate. A granted SMS row is not email consent.

On a clustered master, `consents` already rolls up member guests. You do
not need a guest profile ID to validate that row.

### Optional — load member guests of a clustered master

When `guest_profile_id` is null, load the master:

```bash theme={null}
curl https://api.casa-layer.com/v1/master-profiles/9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

Read `guest_profile_ids`. Each value is a Casa Layer guest profile ID.

### Optional — list stored observations

`GET /v1/consents` returns individual observations. It does not return the
effective rollup. `guest_profile_id` on that route is ingest-time
provenance only.

```bash theme={null}
curl "https://api.casa-layer.com/v1/consents?guest_profile_id=9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d&purpose=marketing&contact_method=email" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

Use this to inspect source rows. Use member `consents` to decide
eligibility.

This search has no `master_profile_id` filter. For a clustered master,
validate `consents` on the member, or call the search once per guest
profile ID.

## If the member list is empty

Work through this list:

1. Confirm the operator clicked **Save** after the filter.
2. Confirm `GET /v1/tables/{id}/filters` shows `marketing_email_consent` is
   `granted`.
3. Confirm ingest has stored consent observations.
4. Confirm you did not expect unknown guests. No row means unknown.
5. Confirm `member_type` matches the grain you want.
6. For a static table, add members by hand. The filter does not run.

## Errors

| Status | Body           | Meaning                                                      |
| ------ | -------------- | ------------------------------------------------------------ |
| `401`  | `unauthorized` | Missing, unknown, or expired token.                          |
| `403`  | `forbidden`    | Missing `read:tables`, or a write from a partner credential. |
| `404`  | `not_found`    | No table with that Casa Layer ID.                            |

An empty member page is not `404`. The list is empty.

## Related

<CardGroup cols={2}>
  <Card title="Partner API credentials" icon="handshake" href="/partner-credentials">
    Create, rotate, and revoke the credential.
  </Card>

  <Card title="Data model" icon="sitemap" href="/data-model#tables">
    Tables, members, and consents.
  </Card>

  <Card title="Browse table members" icon="users" href="/api-reference/browse-table-members">
    `GET /v1/tables/{id}/members` reference.
  </Card>

  <Card title="Search consents" icon="shield-check" href="/api-reference/search-consents">
    Stored consent observations.
  </Card>
</CardGroup>
