Skip to main content

claude_api/
user_profiles.rs

1//! The User Profiles API (beta).
2//!
3//! User profiles let your platform attach Anthropic-managed metadata
4//! and trust grants to your end users. A [`UserProfile`] carries:
5//!
6//! - your platform's own [`external_id`](UserProfile::external_id)
7//!   (e.g. your DB primary key) -- not enforced unique server-side,
8//! - free-form [`metadata`](UserProfile::metadata) (≤16 keys),
9//! - [`trust_grants`](UserProfile::trust_grants) the user has been
10//!   granted (e.g. for elevated content categories), keyed by
11//!   grant name with [`TrustGrantStatus`].
12//!
13//! Trust grants are not granted directly via this API. Instead, call
14//! [`UserProfiles::create_enrollment_url`] to get a signed,
15//! short-lived URL that you redirect the end user to so they can
16//! complete the trust enrollment flow on Anthropic's side.
17//!
18//! # Beta
19//!
20//! Every method automatically sends
21//! `anthropic-beta: user-profiles-2026-03-24`
22//! ([`BetaHeader::UserProfiles`](crate::BetaHeader::UserProfiles)).
23//! Override the beta version on the [`Client`] builder
24//! if a newer revision is current.
25//!
26//! # Endpoints
27//!
28//! | Method | Path | Function |
29//! |---|---|---|
30//! | `POST` | `/v1/user_profiles` | [`UserProfiles::create`] |
31//! | `GET` | `/v1/user_profiles` | [`UserProfiles::list`] |
32//! | `GET` | `/v1/user_profiles/{user_profile_id}` | [`UserProfiles::get`] |
33//! | `POST` | `/v1/user_profiles/{user_profile_id}` | [`UserProfiles::update`] |
34//! | `POST` | `/v1/user_profiles/{user_profile_id}/enrollment_url` | [`UserProfiles::create_enrollment_url`] |
35
36#![cfg(feature = "user-profiles")]
37
38use std::collections::HashMap;
39
40use serde::{Deserialize, Serialize};
41
42use crate::client::Client;
43use crate::error::Result;
44use crate::pagination::PaginatedNextPage;
45
46/// Beta version tag attached to every User Profiles request.
47const USER_PROFILES_BETA: &[&str] = &["user-profiles-2026-03-24"];
48
49// =====================================================================
50// Wire types
51// =====================================================================
52
53/// Status of a single trust grant on a [`UserProfile`].
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55#[serde(rename_all = "snake_case")]
56#[non_exhaustive]
57pub enum TrustGrantStatus {
58    /// Grant is active and the user has been verified.
59    Active,
60    /// Enrollment has been started but not yet completed.
61    Pending,
62    /// Enrollment was attempted and rejected.
63    Rejected,
64}
65
66/// One trust grant on a [`UserProfile`].
67///
68/// Grants live in [`UserProfile::trust_grants`] keyed by grant name
69/// (e.g. `"cyber"`). The keying is open: new grant categories can
70/// appear as Anthropic adds them.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72#[non_exhaustive]
73pub struct TrustGrant {
74    /// Current grant status.
75    pub status: TrustGrantStatus,
76}
77
78/// A user profile resource.
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80#[non_exhaustive]
81pub struct UserProfile {
82    /// Unique identifier (e.g. `uprof_011...`).
83    pub id: String,
84    /// Wire `type`; always `"user_profile"`.
85    #[serde(rename = "type", default = "default_user_profile_kind")]
86    pub kind: String,
87    /// Platform's own identifier for this user (≤255 chars). Not
88    /// enforced unique on the server.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub external_id: Option<String>,
91    /// Free-form key-value metadata (≤16 keys, key ≤64, value ≤512).
92    #[serde(default)]
93    pub metadata: HashMap<String, String>,
94    /// Trust grants on this profile, keyed by grant name. The map is
95    /// empty when no grants are active or in flight.
96    #[serde(default)]
97    pub trust_grants: HashMap<String, TrustGrant>,
98    /// ISO-8601 (RFC 3339) creation timestamp.
99    pub created_at: String,
100    /// ISO-8601 (RFC 3339) last-update timestamp.
101    pub updated_at: String,
102}
103
104fn default_user_profile_kind() -> String {
105    "user_profile".to_owned()
106}
107
108/// A signed, short-lived enrollment URL returned by
109/// [`UserProfiles::create_enrollment_url`].
110///
111/// Redirect the end user to [`Self::url`] before [`Self::expires_at`].
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113#[non_exhaustive]
114pub struct EnrollmentUrl {
115    /// Wire `type`; always `"enrollment_url"`.
116    #[serde(rename = "type", default = "default_enrollment_url_kind")]
117    pub kind: String,
118    /// Enrollment URL to send to the end user.
119    pub url: String,
120    /// Expiry timestamp (RFC 3339). After this point the URL stops
121    /// being valid; request a fresh one.
122    pub expires_at: String,
123}
124
125fn default_enrollment_url_kind() -> String {
126    "enrollment_url".to_owned()
127}
128
129// =====================================================================
130// Request bodies
131// =====================================================================
132
133/// Body for [`UserProfiles::create`]. Both fields optional.
134///
135/// Use [`Self::new`] then chain [`Self::external_id`] and
136/// [`Self::metadata`] / [`Self::metadata_entry`].
137#[derive(Debug, Clone, Default, Serialize)]
138#[non_exhaustive]
139pub struct CreateUserProfileRequest {
140    /// Platform's own identifier (≤255 chars).
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub external_id: Option<String>,
143    /// Initial metadata (≤16 keys, key ≤64, value ≤512, non-empty).
144    #[serde(skip_serializing_if = "HashMap::is_empty")]
145    pub metadata: HashMap<String, String>,
146}
147
148impl CreateUserProfileRequest {
149    /// Empty request.
150    #[must_use]
151    pub fn new() -> Self {
152        Self::default()
153    }
154
155    /// Set the external identifier.
156    #[must_use]
157    pub fn external_id(mut self, id: impl Into<String>) -> Self {
158        self.external_id = Some(id.into());
159        self
160    }
161
162    /// Replace the entire metadata map.
163    #[must_use]
164    pub fn metadata(mut self, metadata: HashMap<String, String>) -> Self {
165        self.metadata = metadata;
166        self
167    }
168
169    /// Add or overwrite one metadata entry.
170    #[must_use]
171    pub fn metadata_entry(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
172        self.metadata.insert(key.into(), value.into());
173        self
174    }
175}
176
177/// Body for [`UserProfiles::update`].
178///
179/// **Merge semantics**: keys provided overwrite existing values; set a
180/// key's value to an empty string to remove it; keys not provided are
181/// left unchanged. `external_id`, when present, replaces the stored
182/// value; omit to leave it unchanged.
183#[derive(Debug, Clone, Default, Serialize)]
184#[non_exhaustive]
185pub struct UpdateUserProfileRequest {
186    /// Replacement external identifier.
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub external_id: Option<String>,
189    /// Metadata patch -- merged into the stored map.
190    #[serde(skip_serializing_if = "HashMap::is_empty")]
191    pub metadata: HashMap<String, String>,
192}
193
194impl UpdateUserProfileRequest {
195    /// Empty patch.
196    #[must_use]
197    pub fn new() -> Self {
198        Self::default()
199    }
200
201    /// Replace the stored `external_id`.
202    #[must_use]
203    pub fn external_id(mut self, id: impl Into<String>) -> Self {
204        self.external_id = Some(id.into());
205        self
206    }
207
208    /// Set or overwrite a metadata key.
209    #[must_use]
210    pub fn set_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
211        self.metadata.insert(key.into(), value.into());
212        self
213    }
214
215    /// Mark a metadata key for removal (sends `""` per the API
216    /// contract).
217    #[must_use]
218    pub fn remove_metadata(mut self, key: impl Into<String>) -> Self {
219        self.metadata.insert(key.into(), String::new());
220        self
221    }
222}
223
224// =====================================================================
225// List query params
226// =====================================================================
227
228/// Sort order for list endpoints.
229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
230#[serde(rename_all = "lowercase")]
231#[non_exhaustive]
232pub enum ListOrder {
233    /// Ascending.
234    Asc,
235    /// Descending.
236    Desc,
237}
238
239impl ListOrder {
240    fn as_str(self) -> &'static str {
241        match self {
242            Self::Asc => "asc",
243            Self::Desc => "desc",
244        }
245    }
246}
247
248/// Query parameters for `GET /v1/user_profiles`.
249#[derive(Debug, Clone, Default)]
250#[non_exhaustive]
251pub struct ListUserProfilesParams {
252    /// Page size.
253    pub limit: Option<u32>,
254    /// Opaque cursor from a previous page's `next_page`.
255    pub page: Option<String>,
256    /// Sort order.
257    pub order: Option<ListOrder>,
258}
259
260impl ListUserProfilesParams {
261    /// Set the page size.
262    #[must_use]
263    pub fn limit(mut self, limit: u32) -> Self {
264        self.limit = Some(limit);
265        self
266    }
267
268    /// Set the pagination cursor.
269    #[must_use]
270    pub fn page(mut self, cursor: impl Into<String>) -> Self {
271        self.page = Some(cursor.into());
272        self
273    }
274
275    /// Set the sort order.
276    #[must_use]
277    pub fn order(mut self, order: ListOrder) -> Self {
278        self.order = Some(order);
279        self
280    }
281
282    fn to_query(&self) -> Vec<(&'static str, String)> {
283        let mut q = Vec::new();
284        if let Some(l) = self.limit {
285            q.push(("limit", l.to_string()));
286        }
287        if let Some(p) = &self.page {
288            q.push(("page", p.clone()));
289        }
290        if let Some(o) = self.order {
291            q.push(("order", o.as_str().to_owned()));
292        }
293        q
294    }
295}
296
297// =====================================================================
298// Namespace handle
299// =====================================================================
300
301/// Namespace handle for the User Profiles API.
302///
303/// Obtained via [`Client::user_profiles`].
304pub struct UserProfiles<'a> {
305    client: &'a Client,
306}
307
308impl<'a> UserProfiles<'a> {
309    pub(crate) fn new(client: &'a Client) -> Self {
310        Self { client }
311    }
312
313    /// `POST /v1/user_profiles`.
314    pub async fn create(&self, request: CreateUserProfileRequest) -> Result<UserProfile> {
315        let body = &request;
316        self.client
317            .execute_with_retry(
318                || {
319                    self.client
320                        .request_builder(reqwest::Method::POST, "/v1/user_profiles")
321                        .json(body)
322                },
323                USER_PROFILES_BETA,
324            )
325            .await
326    }
327
328    /// `GET /v1/user_profiles`.
329    pub async fn list(
330        &self,
331        params: ListUserProfilesParams,
332    ) -> Result<PaginatedNextPage<UserProfile>> {
333        let query = params.to_query();
334        self.client
335            .execute_with_retry(
336                || {
337                    let mut req = self
338                        .client
339                        .request_builder(reqwest::Method::GET, "/v1/user_profiles");
340                    for (k, v) in &query {
341                        req = req.query(&[(k, v)]);
342                    }
343                    req
344                },
345                USER_PROFILES_BETA,
346            )
347            .await
348    }
349
350    /// `GET /v1/user_profiles/{user_profile_id}`.
351    pub async fn get(&self, user_profile_id: &str) -> Result<UserProfile> {
352        let path = format!("/v1/user_profiles/{user_profile_id}");
353        self.client
354            .execute_with_retry(
355                || self.client.request_builder(reqwest::Method::GET, &path),
356                USER_PROFILES_BETA,
357            )
358            .await
359    }
360
361    /// `POST /v1/user_profiles/{user_profile_id}` -- update with merge
362    /// semantics on `metadata`.
363    pub async fn update(
364        &self,
365        user_profile_id: &str,
366        request: UpdateUserProfileRequest,
367    ) -> Result<UserProfile> {
368        let path = format!("/v1/user_profiles/{user_profile_id}");
369        let body = &request;
370        self.client
371            .execute_with_retry(
372                || {
373                    self.client
374                        .request_builder(reqwest::Method::POST, &path)
375                        .json(body)
376                },
377                USER_PROFILES_BETA,
378            )
379            .await
380    }
381
382    /// `POST /v1/user_profiles/{user_profile_id}/enrollment_url` --
383    /// mint a short-lived URL the end user can visit to complete
384    /// trust-grant enrollment.
385    pub async fn create_enrollment_url(&self, user_profile_id: &str) -> Result<EnrollmentUrl> {
386        let path = format!("/v1/user_profiles/{user_profile_id}/enrollment_url");
387        self.client
388            .execute_with_retry(
389                || self.client.request_builder(reqwest::Method::POST, &path),
390                USER_PROFILES_BETA,
391            )
392            .await
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use pretty_assertions::assert_eq;
400    use serde_json::json;
401    use wiremock::matchers::{header_exists, method, path, query_param};
402    use wiremock::{Mock, MockServer, ResponseTemplate};
403
404    fn client_for(mock: &MockServer) -> Client {
405        Client::builder()
406            .api_key("sk-ant-test")
407            .base_url(mock.uri())
408            .build()
409            .unwrap()
410    }
411
412    fn user_profile_json(id: &str) -> serde_json::Value {
413        json!({
414            "id": id,
415            "type": "user_profile",
416            "external_id": "user_12345",
417            "metadata": {"plan": "pro"},
418            "trust_grants": {
419                "cyber": {"status": "active"}
420            },
421            "created_at": "2026-03-15T10:00:00Z",
422            "updated_at": "2026-03-15T10:00:00Z"
423        })
424    }
425
426    #[tokio::test]
427    async fn create_sends_optional_body_and_decodes_profile() {
428        let mock = MockServer::start().await;
429        Mock::given(method("POST"))
430            .and(path("/v1/user_profiles"))
431            .and(header_exists("anthropic-beta"))
432            .respond_with(ResponseTemplate::new(200).set_body_json(user_profile_json("uprof_C1")))
433            .mount(&mock)
434            .await;
435
436        let client = client_for(&mock);
437        let p = client
438            .user_profiles()
439            .create(
440                CreateUserProfileRequest::new()
441                    .external_id("user_12345")
442                    .metadata_entry("plan", "pro"),
443            )
444            .await
445            .unwrap();
446        assert_eq!(p.id, "uprof_C1");
447        assert_eq!(p.external_id.as_deref(), Some("user_12345"));
448        assert_eq!(p.metadata.get("plan").map(String::as_str), Some("pro"));
449        assert_eq!(
450            p.trust_grants.get("cyber").map(|g| g.status),
451            Some(TrustGrantStatus::Active)
452        );
453
454        let recv = &mock.received_requests().await.unwrap()[0];
455        let beta = recv
456            .headers
457            .get("anthropic-beta")
458            .unwrap()
459            .to_str()
460            .unwrap();
461        assert!(beta.contains("user-profiles-2026-03-24"), "{beta}");
462    }
463
464    #[tokio::test]
465    async fn create_omits_empty_metadata_from_request_body() {
466        let mock = MockServer::start().await;
467        Mock::given(method("POST"))
468            .and(path("/v1/user_profiles"))
469            .respond_with(ResponseTemplate::new(200).set_body_json(user_profile_json("uprof_C2")))
470            .mount(&mock)
471            .await;
472
473        let client = client_for(&mock);
474        let _ = client
475            .user_profiles()
476            .create(CreateUserProfileRequest::new())
477            .await
478            .unwrap();
479        let recv = &mock.received_requests().await.unwrap()[0];
480        let body: serde_json::Value = serde_json::from_slice(&recv.body).unwrap();
481        // Empty body so the API uses defaults; both fields skipped.
482        assert!(body.get("metadata").is_none(), "{body}");
483        assert!(body.get("external_id").is_none(), "{body}");
484    }
485
486    #[tokio::test]
487    async fn list_passes_limit_page_order_and_decodes_no_has_more() {
488        let mock = MockServer::start().await;
489        Mock::given(method("GET"))
490            .and(path("/v1/user_profiles"))
491            .and(query_param("limit", "10"))
492            .and(query_param("order", "desc"))
493            .and(query_param("page", "page_X"))
494            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
495                "data": [user_profile_json("uprof_L1")],
496                "next_page": "page_Y"
497            })))
498            .mount(&mock)
499            .await;
500
501        let client = client_for(&mock);
502        let page = client
503            .user_profiles()
504            .list(
505                ListUserProfilesParams::default()
506                    .limit(10)
507                    .page("page_X")
508                    .order(ListOrder::Desc),
509            )
510            .await
511            .unwrap();
512        assert_eq!(page.data.len(), 1);
513        assert_eq!(page.next_cursor(), Some("page_Y"));
514        // List response omits `has_more`; envelope should default to false.
515        assert!(!page.has_more);
516    }
517
518    #[tokio::test]
519    async fn get_decodes_single_profile() {
520        let mock = MockServer::start().await;
521        Mock::given(method("GET"))
522            .and(path("/v1/user_profiles/uprof_G1"))
523            .respond_with(ResponseTemplate::new(200).set_body_json(user_profile_json("uprof_G1")))
524            .mount(&mock)
525            .await;
526
527        let client = client_for(&mock);
528        let p = client.user_profiles().get("uprof_G1").await.unwrap();
529        assert_eq!(p.id, "uprof_G1");
530        assert_eq!(p.kind, "user_profile");
531    }
532
533    #[tokio::test]
534    async fn update_sends_metadata_merge_patch_with_empty_string_for_deletion() {
535        let mock = MockServer::start().await;
536        Mock::given(method("POST"))
537            .and(path("/v1/user_profiles/uprof_U1"))
538            .respond_with(ResponseTemplate::new(200).set_body_json(user_profile_json("uprof_U1")))
539            .mount(&mock)
540            .await;
541
542        let client = client_for(&mock);
543        let _ = client
544            .user_profiles()
545            .update(
546                "uprof_U1",
547                UpdateUserProfileRequest::new()
548                    .set_metadata("plan", "enterprise")
549                    .remove_metadata("legacy_flag"),
550            )
551            .await
552            .unwrap();
553
554        let recv = &mock.received_requests().await.unwrap()[0];
555        let body: serde_json::Value = serde_json::from_slice(&recv.body).unwrap();
556        assert_eq!(body["metadata"]["plan"], "enterprise");
557        // Removal is encoded as an empty string per the API contract.
558        assert_eq!(body["metadata"]["legacy_flag"], "");
559    }
560
561    #[tokio::test]
562    async fn create_enrollment_url_returns_signed_url_and_expiry() {
563        let mock = MockServer::start().await;
564        Mock::given(method("POST"))
565            .and(path("/v1/user_profiles/uprof_E1/enrollment_url"))
566            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
567                "type": "enrollment_url",
568                "url": "https://platform.claude.com/user-profiles/enrollment/abc123",
569                "expires_at": "2026-03-15T10:15:00Z"
570            })))
571            .mount(&mock)
572            .await;
573
574        let client = client_for(&mock);
575        let url = client
576            .user_profiles()
577            .create_enrollment_url("uprof_E1")
578            .await
579            .unwrap();
580        assert!(url.url.contains("enrollment/abc123"));
581        assert_eq!(url.expires_at, "2026-03-15T10:15:00Z");
582        assert_eq!(url.kind, "enrollment_url");
583    }
584
585    #[test]
586    fn trust_grant_status_round_trips_known_values() {
587        let g: TrustGrant = serde_json::from_value(json!({"status": "pending"})).unwrap();
588        assert_eq!(g.status, TrustGrantStatus::Pending);
589        let g: TrustGrant = serde_json::from_value(json!({"status": "rejected"})).unwrap();
590        assert_eq!(g.status, TrustGrantStatus::Rejected);
591        let json = serde_json::to_value(TrustGrant {
592            status: TrustGrantStatus::Active,
593        })
594        .unwrap();
595        assert_eq!(json, json!({"status": "active"}));
596    }
597
598    #[test]
599    fn list_order_serializes_as_lowercase() {
600        assert_eq!(ListOrder::Asc.as_str(), "asc");
601        assert_eq!(ListOrder::Desc.as_str(), "desc");
602        let v = serde_json::to_value(ListOrder::Desc).unwrap();
603        assert_eq!(v, json!("desc"));
604    }
605
606    #[test]
607    fn user_profile_tolerates_missing_optional_fields() {
608        // Server may return a profile with no external_id, no metadata,
609        // and no trust_grants.
610        let raw = json!({
611            "id": "uprof_M1",
612            "type": "user_profile",
613            "created_at": "2026-03-15T10:00:00Z",
614            "updated_at": "2026-03-15T10:00:00Z"
615        });
616        let p: UserProfile = serde_json::from_value(raw).unwrap();
617        assert_eq!(p.id, "uprof_M1");
618        assert!(p.external_id.is_none());
619        assert!(p.metadata.is_empty());
620        assert!(p.trust_grants.is_empty());
621    }
622}