# Record consent

Stores publisher consent state for Script Tag and custom consent flows.

Projects using wavebird_consent require a current stored consent record for the authenticated tenant, client_id, and session_id. Record the actual user decision through /v1/consent before placement; inline flags cannot replace or expand that permission. Other project modes may accept request-level flags; they do not bypass origin, protocol, GPC, or activation gates.

| Method | Path | Authentication | Stability |
| --- | --- | --- | --- |
| POST | /v1/consent | Secret key, or activation token for browser flows | beta |

## Request fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| client_id | string | Yes | Project ID (client_id, WAVEBIRD_CLIENT_ID), formatted like wbproj_.... |
| session_id | string | Yes | Publisher session identifier. |
| decision | string | Yes | Consent decision: personalized, basic, or custom. |
| source | string | Yes | Consent source: publisher_custom, server_sync, or wavebird_dialog. Compatibility aliases publisher and custom_dialog normalize to publisher_custom but are not recommended for new integrations. |
| lifecycle_state | string | No | Explicit session state: granted, denied, withdrawn, revoked, or cmp_error. Omission retains an existing state; it does not reopen a withdrawn record. For synthetic staff Test sessions, record an explicitly scoped test authorization. |
| revision | integer | No | Positive state revision. Omission allocates the next revision; when supplied, stale or conflicting updates are rejected. Use a newer explicit granted state only after a fresh authorization decision. |
| purposes | object | No | Purpose-level consent flags: semantic_targeting, session_persistence, cross_session_persistence, and prompt_shared. Compatibility purposes.ads maps to semantic_targeting and purposes.measurement maps to session_persistence. |

## Response fields

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| consent_id | string | Yes | Stored consent record identifier. |
| session_id | string | Yes | Publisher session identifier for the stored consent state. |
| expires_at | string | Yes | ISO timestamp when the stored consent state expires. |
| authoritative_consent | object | Yes | Complete stored lifecycle snapshot: optional consent_id, lifecycle_state, revision, update and expiry timestamps, source, and purposes. |
| normalized.source | string | Yes | Canonical source after compatibility alias mapping. |
| normalized.purposes | object | No | Canonical purpose flags after compatibility ads/measurement mapping. |

## Errors

| Status | Code | Description |
| --- | --- | --- |
| 401 | unauthorized | Missing auth. |
| 403 | forbidden | Publishable key origin is not allowed, or raw publishable-key auth was used instead of a browser activation token. |
| 400 | validation_error | Consent purposes are missing or malformed. |
| 409 | consent_state_conflict | A stale lifecycle revision cannot replace the current session state. |
| 409 | consent_state_invalid | The lifecycle transition or stored scope is invalid. |

## Response example

```json
{
  "ok": true,
  "consent_id": "consent_demo_123",
  "session_id": "sess_demo_123",
  "expires_at": "2026-08-02T08:31:00.000Z",
  "normalized": {
    "source": "publisher_custom",
    "purposes": {
      "semantic_targeting": false,
      "session_persistence": true,
      "cross_session_persistence": false,
      "prompt_shared": false
    }
  }
}
```

## Placement consent vs /v1/consent sync
Projects using wavebird_consent require a current stored consent record for the authenticated tenant, client_id, and session_id. Record the actual user decision through /v1/consent before placement; inline flags cannot replace or expand that permission.
Only synchronize a decision actually obtained for this session and its purposes. A documentation example is not consent. TCF and GPP inputs are currently rejected at admission with validation_error (400), including structurally valid strings, because an authoritative vendor, purpose, and jurisdiction policy mapping is not configured. Do not interpret field support as permission to deliver ads. An observed positive Sec-GPC signal blocks new advertising admission with privacy_signal_policy_required (403). Preserve the signal across backend requests and keep the primary application flow available; do not retry by removing the signal.
On withdrawal, synchronize lifecycle_state: withdrawn with a newer revision for the same authenticated project and session. Subsequent placements fail closed with consent_not_current (403). Replaying an older grant returns consent_state_conflict (409); inline placement flags cannot restore authorization. Missing, expired, invalid, foreign-scope or unavailable stored authorization also denies advertising while the primary application remains available.

| Consent path | Location | Purpose | Required before placement |
| --- | --- | --- | --- |
| Request-level placement consent | inside /v1/placements | per-request privacy and targeting signal | Does not replace required stored consent |
| Consent sync | /v1/consent | session-level publisher consent state | Required for wavebird_consent projects |

## Canonical /v1/consent sync body and compatibility aliases
This body illustrates a recorded decision. Replace its purpose flags with the actual session decision; never grant permission merely to make a placement succeed.
Compatibility aliases remain supported after the canonical form: source: "publisher" normalizes to publisher_custom, purposes.ads maps to semantic_targeting, and purposes.measurement maps to session_persistence.

```json
{
  "client_id": "wbproj_...",
  "session_id": "sess_...",
  "decision": "custom",
  "source": "publisher_custom",
  "purposes": {
    "semantic_targeting": false,
    "session_persistence": true,
    "cross_session_persistence": false,
    "prompt_shared": false
  }
}
```

## cURL

```bash
curl -X POST https://api.wavebird.ai/v1/consent \
  -H "Authorization: Bearer sk_test_wavebird_demo_secret" \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "wbproj_demo_8jK42",
    "session_id": "sess_demo_123",
    "decision": "custom",
    "source": "publisher_custom",
    "purposes": {
      "semantic_targeting": false,
      "session_persistence": true,
      "cross_session_persistence": false,
      "prompt_shared": false
    }
  }'
```

## Node

```javascript
const response = await fetch("https://api.wavebird.ai/v1/consent", {
  method: "POST",
  headers: {
    Authorization: "Bearer sk_test_wavebird_demo_secret",
    "Content-Type": "application/json"
  },
  body: JSON.stringify({
  client_id: "wbproj_demo_8jK42",
  session_id: "sess_demo_123",
  decision: "custom",
  source: "publisher_custom",
  purposes: {
    semantic_targeting: false,
    session_persistence: true,
    cross_session_persistence: false,
    prompt_shared: false
  }
})
});

const data = await response.json();
console.log(data);
```

## Python

```python
import json
import urllib.request

payload = json.loads("{\"client_id\":\"wbproj_demo_8jK42\",\"session_id\":\"sess_demo_123\",\"decision\":\"custom\",\"source\":\"publisher_custom\",\"purposes\":{\"semantic_targeting\":false,\"session_persistence\":true,\"cross_session_persistence\":false,\"prompt_shared\":false}}")

request = urllib.request.Request(
    "https://api.wavebird.ai/v1/consent",
    data=json.dumps(payload).encode("utf-8"),
    headers={
        "Authorization": "Bearer sk_test_wavebird_demo_secret",
        "Content-Type": "application/json",
    },
    method="POST",
)

with urllib.request.urlopen(request) as response:
    print(response.read().decode("utf-8"))
```
