---
title: Profiles
description: Upsert a user profile's traits and value score with PUT /api/v1/profile.
---

`PUT /api/v1/profile` writes traits (and an optional value score) to the same
`identified_users` table the `identify` ingestion path uses. Use it when you have
richer profile data to push than what naturally flows through `identify` — for
example, a nightly job syncing computed lifetime-value scores.

## Endpoint

```
PUT https://api.retidal.com/api/v1/profile
```

**Auth**: `X-API-Key` with scope `profile:write`.

**Rate limit**: 60 requests/minute per (project, IP).

<Warning>
  Do not include `projectId` in the body — it's always derived from the
  authenticated API key, and the request schema doesn't accept it.
</Warning>

## Request body

<ParamField name="userId" in="body" type="string" required>
  Non-empty user/profile ID.
</ParamField>
<ParamField name="traits" in="body" type="object" required>
  Trait map, validated by the same rules as `identify`'s traits — see
  [Identifying users](/docs/sending-data/identify#trait-rules): ≤ 8 KiB
  serialized, ≤ 2 levels nested, dangerous keys (like anything containing
  `password` or `token`) rejected.
</ParamField>
<ParamField name="valueScore" in="body" type="number">
  Optional value score. Accepts `null`.
</ParamField>

```json title="Request"
{
  "userId": "uid_456",
  "traits": { "plan": "pro", "region": "apac" },
  "valueScore": 87.5
}
```

## Response

The response is only returned after the D1 upsert completes — this is
at-least-once, never fire-and-forget.

```json title="200"
{ "ok": true }
```

## Errors

| Status | Meaning |
| --- | --- |
| `400` | `PUT /api/v1/profile`: Invalid JSON, or `invalid_payload` (bad `userId`, `traits`, or `valueScore`, or a traits-sanitizer rejection — the response includes a `detail` string). |
| `401` | `PUT /api/v1/profile`: missing (`missing_api_key`) or unresolvable (`unauthorized`) API key. |
| `403` | `PUT /api/v1/profile`: the key lacks the `profile:write` scope (`insufficient_scope`). |
| `404` | `PUT /api/v1/profile`: cross-project key — tenant isolation (`not_found`). |
| `429` | `PUT /api/v1/profile`: rate limit exceeded (60/min per project + IP) (`rate_limited`). |

## Next steps

<CardGroup cols={2}>
  <Card title="Decide" href="/docs/decisioning/decide">
    Evaluate decision rules against the traits you write here.
  </Card>
  <Card title="Identifying users" href="/docs/sending-data/identify">
    The ingestion-side path that also writes to this same profile store.
  </Card>
</CardGroup>
