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

# POST /users/attributes

> Endpoint contract for POST /users/attributes: store targeting attributes against a user for later offer requests.

Stores a set of targeting attributes against one user so later offer requests are targeted on them without the values traveling in a link. Your [hosted offer page](/publishers/web/guides/hosted-offer-page) URL then carries no attributes at all, just `apiKey`, `userId`, and whichever presentation parameters you use.

```http theme={null}
POST https://api.encorekit.com/encore/publisher/sdk/v1/users/attributes
```

<Note>
  Call it before you open the offer page for a user, or ahead of your [`POST /offers/feed`](/publishers/offers-api/reference/feed), and again whenever what you know about that user changes. To attach attributes to a single request instead of storing them, put them in that request's own `attributes` object: a request attribute always wins over the stored one.
</Note>

## Authentication

Pass your publishable key in the `X-API-Key` header (`pk_live_*` for production, `pk_test_*` for development). This is the same guard [`POST /offers/feed`](/publishers/offers-api/reference/feed#authentication) uses, so no new credential is involved. Multi-app projects also send `X-Platform`.

<Warning>
  **Store against the app that serves the offers.** The set is keyed on the app your key resolves plus `userId`, so a set stored against one app in a multi-app project is invisible to another. The hosted offer page runs the Web SDK, which sends `X-Platform: web`, so a multi-app project storing attributes for that page sends `X-Platform: web` here too.
</Warning>

## How the stored set is used

* **Each call replaces the last one.** The write is idempotent on the app plus `userId`, and the newest set is the whole truth. Attributes are never merged into what was stored before, so send the complete set every time.
* **There is no expiry.** The last set stays authoritative until a newer one arrives, and a data-subject erasure request removes it.
* **A request attribute always wins.** [`POST /offers/feed`](/publishers/offers-api/reference/feed), and the `POST /offers/search` call the Web SDK makes on your behalf, read the stored set only to fill an attribute the request itself omitted. [`POST /offers/catalog`](/publishers/offers-api/reference/catalog) and [`POST /offers/message`](/publishers/offers-api/reference/message) do not read it.
* **Storing an attribute never makes it optional on a request.** `/offers/feed` still requires `countryCode` and `language` in its own body, and rejects a request that omits them before the stored set is read. What the store fills on that endpoint is everything else.
* **The user does not have to exist yet.** The first call for a `userId` creates the user, so there is no ordering requirement against any other endpoint.

## Request

### Structure

```typescript theme={null}
interface StoreUserAttributesRequest {
  userId: string;                  // required: the same end-user id your offer requests use
  attributes: {                    // required: replaces any previously stored set
    // Accepted from any app
    countryCode?: string;          // ISO 3166-1 alpha-2, two uppercase letters
    language?: string;             // BCP-47 language tag
    city?: string;
    state?: string;
    region?: string;
    subscriptionTier?: string;
    monthsSubscribed?: string;
    billingCycle?: string;
    lastPaymentAmount?: string;
    lastActiveDate?: string;
    totalSessions?: string;
    custom?: Record<string, string>;
    age?: number;                  // whole years, integer 0-120; see Sending an age

    // Accepted only from an app declared adults_only
    email?: string;
    firstName?: string;
    lastName?: string;
    mobile?: string;
    phoneNumber?: string;
    postalCode?: string;           // prefer this spelling
    postcode?: string;             // a separate key, not an alias
    latitude?: string;             // decimal degrees as a string
    longitude?: string;            // decimal degrees as a string
    gender?: string;
  };
}
```

### Field Reference

| Field                          | Type    | Required | Description                                                                                                                                                                                                                                                             |
| ------------------------------ | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `userId`                       | string  | Yes      | Your stable end-user identifier, the same one your offer requests and your offer page URL carry. It is the key the stored set is filed under.                                                                                                                           |
| `attributes`                   | object  | Yes      | The complete set to store. **Replaces** any set stored earlier for this user. An empty object is valid and clears the stored set.                                                                                                                                       |
| `attributes.countryCode`       | string  | No       | ISO 3166-1 alpha-2, two uppercase letters (for example `"US"`, `"GB"`, `"CA"`). Drives geo-targeting and the country dimension on analytics.                                                                                                                            |
| `attributes.language`          | string  | No       | BCP-47 language tag (for example `"en"`, `"es"`). Drives creative locale resolution.                                                                                                                                                                                    |
| `attributes.city`              | string  | No       | Coarse geography, accepted from any app.                                                                                                                                                                                                                                |
| `attributes.state`             | string  | No       | Coarse geography, accepted from any app.                                                                                                                                                                                                                                |
| `attributes.region`            | string  | No       | Coarse geography, accepted from any app.                                                                                                                                                                                                                                |
| `attributes.subscriptionTier`  | string  | No       | Your own tier label (for example `"premium"`).                                                                                                                                                                                                                          |
| `attributes.monthsSubscribed`  | string  | No       | Months the user has been subscribed, as a string.                                                                                                                                                                                                                       |
| `attributes.billingCycle`      | string  | No       | Your own cycle label (for example `"annual"`).                                                                                                                                                                                                                          |
| `attributes.lastPaymentAmount` | string  | No       | Last payment amount, as a string.                                                                                                                                                                                                                                       |
| `attributes.lastActiveDate`    | string  | No       | ISO 8601 datetime the user was last active (for example `2024-03-15T14:30:00Z`).                                                                                                                                                                                        |
| `attributes.totalSessions`     | string  | No       | Session count, as a string.                                                                                                                                                                                                                                             |
| `attributes.custom`            | object  | No       | Free-form string map for app-specific values (for example `{ "plan": "gold" }`). Values are strings.                                                                                                                                                                    |
| `attributes.age`               | integer | No       | Whole years, `0` to `120`. Accepted from **any** app. Read [Sending an age](#sending-an-age) and [What is stored for a user under 18](#what-is-stored-for-a-user-under-18) before you send it: the range rule and what an under-18 age does are both easy to get wrong. |
| `attributes.email`             | string  | No       | **Adults-only apps only.** See [Attributes that need an adults-only app](#attributes-that-need-an-adults-only-app).                                                                                                                                                     |
| `attributes.firstName`         | string  | No       | **Adults-only apps only.**                                                                                                                                                                                                                                              |
| `attributes.lastName`          | string  | No       | **Adults-only apps only.**                                                                                                                                                                                                                                              |
| `attributes.mobile`            | string  | No       | **Adults-only apps only.**                                                                                                                                                                                                                                              |
| `attributes.phoneNumber`       | string  | No       | **Adults-only apps only.**                                                                                                                                                                                                                                              |
| `attributes.postalCode`        | string  | No       | **Adults-only apps only.** Precise location, unlike `city` and `state`. **Prefer this one:** it is the spelling every Encore SDK and the offer page URL use.                                                                                                            |
| `attributes.postcode`          | string  | No       | **Adults-only apps only.** A separate key, not an alias of `postalCode`, and stored under its own name. Accepted for callers whose own schema uses this spelling. Send one or the other, not both.                                                                      |
| `attributes.latitude`          | string  | No       | **Adults-only apps only.** Decimal degrees as a string (for example `"37.7749"`).                                                                                                                                                                                       |
| `attributes.longitude`         | string  | No       | **Adults-only apps only.** Decimal degrees as a string (for example `"-122.4194"`).                                                                                                                                                                                     |
| `attributes.gender`            | string  | No       | **Adults-only apps only.**                                                                                                                                                                                                                                              |

### Sending an age

`age` is an integer count of whole years. It is accepted from every app, whatever that app's audience declaration says, because it is what narrows which offers a user may be shown rather than something that identifies them.

<Warning>
  **If you hold only a range, send its lowest end.** `"13-17"` is sent as `13`, never `15` and never `17`. Sending the middle of a range defeats the check silently: flooring can never mask a minor, and a midpoint can.
</Warning>

**An age under 18 does not switch offers off.** That user is still served. What changes is that the offers come from age-appropriate inventory only, ranked by the app-level bandit, with no per-user personalization. The response reports `minor: true` so you can see which branch a user took.

The instant you sent the set is recorded, and the age acted on is counted forward from it. A user stored at 17 becomes an adult on schedule rather than staying 17 forever.

Send `age`, never a date of birth. `dateOfBirth` is refused here: an age carries the same targeting value with far less identifying power.

### What is stored for a user under 18

Only `age`, `countryCode`, and `language` are kept. Everything else you sent is discarded, `custom` included, along with `city`, `state`, `region`, and every subscription and billing field.

Those three survive because of what they do. `age` narrows which offers the user may be shown, `countryCode` decides where an offer may legally appear, and `language` picks the creative locale that renders. None of the three chooses an advertiser. Everything else exists to personalize the pick, and personalization is withheld for this user, so storing it would keep data that can never be acted on.

<Warning>
  **This is silent, and it is not an error.** The call returns `200`, and the only sign is `minor: true` in the response. Send `subscriptionTier` for a 16-year-old and you get success, no warning, and no `subscriptionTier` in the stored set afterwards.
</Warning>

It is decided per request, from the age that request carried, so this is not a separate integration to build. The same set sent for an adult is stored whole. Once a user is 18, re-send their set and it is stored in full.

### Attributes that need an adults-only app

`email`, `firstName`, `lastName`, `mobile`, `phoneNumber`, `postcode`, `postalCode`, `latitude`, `longitude`, and `gender` are accepted only from an app whose audience is declared `adults_only`, meaning it is rated 18+ in its store listing. Every other status is refused with a `400`, including the default status an app carries before anyone has declared its audience.

The refusal names the keys it rejected and stores nothing at all, not even the attributes that would have been allowed:

```json theme={null}
{
  "error": "These attributes are accepted only from an app rated 18+ in its store listing: email, gender. This app's child_audience_status is \"unknown\", not \"adults_only\". Re-send without them, or have the app's audience declaration corrected first."
}
```

Re-send without those keys, or have the app's audience declaration corrected in the [dashboard](https://dashboard.encorekit.com) first.

Neither `postalCode` nor `postcode` reaches offer selection today, so an app that cannot send them loses no targeting by leaving them out.

### Attributes that are refused

| Key           | Why                                                                                                                                                                     |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `dateOfBirth` | Refused from every app, including adults-only ones. Send `age` instead.                                                                                                 |
| `interests`   | Not part of this vocabulary. There is no substitute key.                                                                                                                |
| Anything else | Any key not listed in [Field Reference](#field-reference) is refused rather than ignored, so a typo surfaces as a `400` instead of as targeting that quietly does less. |

## Response

### Structure

```typescript theme={null}
interface StoreUserAttributesResponse {
  success: true;
  userId: string;      // canonical user UUID, not the id you sent
  storedAt: string;    // ISO 8601 instant the set was recorded
  minor: boolean;      // true when the age you sent was under 18
}
```

### Field Reference

| Field      | Type    | Description                                                                                                                                                                                                                                                                                                                                                |
| ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `success`  | `true`  | Always `true` on a 200. Non-success outcomes come back as 4xx/5xx; see [Status codes](#status-codes).                                                                                                                                                                                                                                                      |
| `userId`   | UUID    | Encore's canonical user id, which is **not** the `userId` you sent. Keep using your own id on every other endpoint.                                                                                                                                                                                                                                        |
| `storedAt` | string  | ISO 8601 instant the set was recorded. This is what a stored `age` is counted forward from.                                                                                                                                                                                                                                                                |
| `minor`    | boolean | `true` when the `age` you sent was under 18, `false` otherwise (including when you sent no age). **Offers are still served either way.** A `true` means this user was served from age-appropriate inventory and that only three attributes were kept from the set you sent. See [What is stored for a user under 18](#what-is-stored-for-a-user-under-18). |

## Example

```bash theme={null}
curl --location --request POST 'https://api.encorekit.com/encore/publisher/sdk/v1/users/attributes' \
  --header 'X-API-Key: YOUR_API_KEY' \
  --header 'Content-Type: application/json' \
  --data '{
    "userId": "user-123",
    "attributes": {
      "countryCode": "US",
      "language": "en",
      "subscriptionTier": "gold",
      "monthsSubscribed": "14",
      "age": 34,
      "custom": { "machine": "kiosk-4471" }
    }
  }'
```

```json theme={null}
{
  "success": true,
  "userId": "550e8400-e29b-41d4-a716-446655440000",
  "storedAt": "2026-09-02T10:14:07.412Z",
  "minor": false
}
```

## Status codes

| Code          | Meaning                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `200`         | The set was stored and replaces any previous one.                                                                                                                                                                                                                                                                                                                                                                                                       |
| `400`         | A gated key from an app that is not `adults_only`, a refused or unrecognized key, an `age` that is not a whole number between `0` and `120`, or a missing `userId`. Nothing was stored. Don't retry; fix the request. The gate returns the sentence shown under [Attributes that need an adults-only app](#attributes-that-need-an-adults-only-app); a schema failure returns the offending field in `details` instead. Both are `{ error, details? }`. |
| `401`         | Missing or invalid `X-API-Key`, or an `X-Platform` value your project has no app for.                                                                                                                                                                                                                                                                                                                                                                   |
| `503`         | Server temporarily over capacity. Retry with backoff.                                                                                                                                                                                                                                                                                                                                                                                                   |
| `5xx` (other) | Encore-side failure.                                                                                                                                                                                                                                                                                                                                                                                                                                    |

### Retry guidance

* **Retrying is safe, but only useful for some failures.** The call is an idempotent replace, so sending the same set twice leaves exactly the state one call would have. This is unlike the offer-selection endpoints, where each call mints fresh links and analytics rows. A `400` or `401` is deterministic, so repeating it produces the same refusal: fix the request instead.
* Use exponential backoff with jitter (for example 200ms, 1s, 5s, max 3 retries) for transient failures (503 / network).
* After exhausting retries, open the offer page anyway. A user with no stored set is served from what the request itself carries, so the worst case is less targeted, never nothing.

## Related

* [Hosted Offer Page](/publishers/web/guides/hosted-offer-page): the page these attributes target, and how to move off URL parameters.
* [`POST /offers/feed`](/publishers/offers-api/reference/feed): the ranked selection that reads the stored set for attributes its own body omits.
* [API Reference overview](/publishers/offers-api/overview): auth and the claim model.
