Skip to main content

ghl_sdk/
client.rs

1//! The core HTTP client: request building, auth, retries, and rate-limit tracking.
2
3use std::sync::atomic::{AtomicI64, Ordering};
4use std::sync::Arc;
5use std::time::Duration;
6
7use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION};
8use reqwest::{Method, StatusCode};
9use serde::de::DeserializeOwned;
10use serde::Serialize;
11
12use crate::auth::Auth;
13use crate::calendars::CalendarsService;
14use crate::contacts::ContactsService;
15use crate::conversations::ConversationsService;
16use crate::error::{Error, Result};
17use crate::locations::LocationsService;
18use crate::opportunities::OpportunitiesService;
19
20/// Production API base URL.
21pub const DEFAULT_BASE_URL: &str = "https://services.leadconnectorhq.com";
22
23/// The `Version` header value used by most API 2.0 modules.
24pub const API_VERSION: &str = "2021-07-28";
25
26const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
27const DEFAULT_MAX_RETRIES: u32 = 3;
28const BACKOFF_BASE: Duration = Duration::from_millis(500);
29const BACKOFF_CAP: Duration = Duration::from_secs(8);
30
31/// Async client for the GoHighLevel API 2.0.
32///
33/// Cloning is cheap (`Arc` internally); share one client across tasks.
34///
35/// ```no_run
36/// # async fn demo() -> Result<(), ghl_sdk::Error> {
37/// // From the environment (GHL_PIT_TOKEN or GHL_ACCESS_TOKEN, optional GHL_BASE_URL):
38/// let ghl = ghl_sdk::Ghl::from_env()?;
39/// // Or explicitly:
40/// let ghl = ghl_sdk::Ghl::builder()
41///     .private_integration_token("pit-…")
42///     .build()?;
43/// # Ok(()) }
44/// ```
45#[derive(Clone)]
46pub struct Ghl {
47    inner: Arc<Inner>,
48}
49
50struct Inner {
51    http: reqwest::Client,
52    base_url: String,
53    auth: Auth,
54    max_retries: u32,
55    /// Last-seen `X-RateLimit-Remaining` (burst window); -1 = unknown.
56    rate_remaining: AtomicI64,
57    /// Last-seen `X-RateLimit-Daily-Remaining`; -1 = unknown.
58    rate_daily_remaining: AtomicI64,
59}
60
61impl std::fmt::Debug for Ghl {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_struct("Ghl")
64            .field("base_url", &self.inner.base_url)
65            .field("auth", &self.inner.auth)
66            .finish_non_exhaustive()
67    }
68}
69
70/// Builder for [`Ghl`].
71#[derive(Default)]
72pub struct GhlBuilder {
73    base_url: Option<String>,
74    auth: Option<Auth>,
75    timeout: Option<Duration>,
76    max_retries: Option<u32>,
77}
78
79impl GhlBuilder {
80    /// Override the API base URL (e.g. for tests or a proxy).
81    pub fn base_url(mut self, url: impl Into<String>) -> Self {
82        self.base_url = Some(url.into());
83        self
84    }
85
86    /// Authenticate with a Private Integration Token (`pit-…`).
87    pub fn private_integration_token(mut self, token: impl Into<String>) -> Self {
88        self.auth = Some(Auth::private_integration(token));
89        self
90    }
91
92    /// Authenticate with a pre-obtained OAuth access token (no refresh).
93    pub fn access_token(mut self, token: impl Into<String>) -> Self {
94        self.auth = Some(Auth::access_token(token));
95        self
96    }
97
98    /// Authenticate with any [`Auth`] variant (e.g. full OAuth with refresh).
99    pub fn auth(mut self, auth: Auth) -> Self {
100        self.auth = Some(auth);
101        self
102    }
103
104    /// Per-request timeout (default 30s).
105    pub fn timeout(mut self, timeout: Duration) -> Self {
106        self.timeout = Some(timeout);
107        self
108    }
109
110    /// Maximum retry attempts after the initial request (default 3).
111    pub fn max_retries(mut self, retries: u32) -> Self {
112        self.max_retries = Some(retries);
113        self
114    }
115
116    /// Finalize the client. Errors if no credentials were provided.
117    pub fn build(self) -> Result<Ghl> {
118        let auth = self.auth.ok_or_else(|| {
119            Error::Config(
120                "no credentials configured — call `.private_integration_token(…)`, \
121                 `.access_token(…)`, or `.auth(…)`"
122                    .into(),
123            )
124        })?;
125        let base_url = self
126            .base_url
127            .unwrap_or_else(|| DEFAULT_BASE_URL.to_owned())
128            .trim_end_matches('/')
129            .to_owned();
130        let http = reqwest::Client::builder()
131            .timeout(self.timeout.unwrap_or(DEFAULT_TIMEOUT))
132            .user_agent(concat!("ghl-sdk/", env!("CARGO_PKG_VERSION")))
133            .build()?;
134        Ok(Ghl {
135            inner: Arc::new(Inner {
136                http,
137                base_url,
138                auth,
139                max_retries: self.max_retries.unwrap_or(DEFAULT_MAX_RETRIES),
140                rate_remaining: AtomicI64::new(-1),
141                rate_daily_remaining: AtomicI64::new(-1),
142            }),
143        })
144    }
145}
146
147/// A snapshot of the most recently observed rate-limit headers.
148#[derive(Debug, Clone, Copy, PartialEq, Eq)]
149pub struct RateStatus {
150    /// Remaining requests in the current burst window (100 req / 10s), if known.
151    pub burst_remaining: Option<i64>,
152    /// Remaining requests today (200k/day), if known.
153    pub daily_remaining: Option<i64>,
154}
155
156impl Ghl {
157    /// Start configuring a client.
158    pub fn builder() -> GhlBuilder {
159        GhlBuilder::default()
160    }
161
162    /// Build a client from environment variables.
163    ///
164    /// - `GHL_PIT_TOKEN` — Private Integration Token, **or**
165    /// - `GHL_ACCESS_TOKEN` — OAuth access token
166    /// - `GHL_BASE_URL` — optional base-URL override
167    pub fn from_env() -> Result<Self> {
168        let mut builder = Ghl::builder();
169        if let Ok(url) = std::env::var("GHL_BASE_URL") {
170            builder = builder.base_url(url);
171        }
172        if let Ok(token) = std::env::var("GHL_PIT_TOKEN") {
173            builder = builder.private_integration_token(token);
174        } else if let Ok(token) = std::env::var("GHL_ACCESS_TOKEN") {
175            builder = builder.access_token(token);
176        } else {
177            return Err(Error::Config(
178                "set GHL_PIT_TOKEN (or GHL_ACCESS_TOKEN) in the environment, \
179                 or use `Ghl::builder()` to pass credentials as parameters"
180                    .into(),
181            ));
182        }
183        builder.build()
184    }
185
186    /// Contacts API.
187    pub fn contacts(&self) -> ContactsService {
188        ContactsService::new(self.clone())
189    }
190
191    /// Locations (sub-accounts) API.
192    pub fn locations(&self) -> LocationsService {
193        LocationsService::new(self.clone())
194    }
195
196    /// Opportunities (pipeline deals) API.
197    pub fn opportunities(&self) -> OpportunitiesService {
198        OpportunitiesService::new(self.clone())
199    }
200
201    /// Conversations (threads, messages, sending) API.
202    pub fn conversations(&self) -> ConversationsService {
203        ConversationsService::new(self.clone())
204    }
205
206    /// Calendars (calendars, free slots, appointments) API.
207    pub fn calendars(&self) -> CalendarsService {
208        CalendarsService::new(self.clone())
209    }
210
211    /// Typed access to the `ad-manager` API v2 surface.
212    ///
213    /// Requires the `ad-manager` cargo feature.
214    #[cfg(feature = "ad-manager")]
215    #[cfg_attr(docsrs, doc(cfg(feature = "ad-manager")))]
216    pub fn ad_manager(&self) -> crate::services::ad_manager::AdManagerService {
217        crate::services::ad_manager::AdManagerService::new(self.clone())
218    }
219
220    /// Typed access to the `affiliate-manager` API v2 surface.
221    ///
222    /// Requires the `affiliate-manager` cargo feature.
223    #[cfg(feature = "affiliate-manager")]
224    #[cfg_attr(docsrs, doc(cfg(feature = "affiliate-manager")))]
225    pub fn affiliate_manager(&self) -> crate::services::affiliate_manager::AffiliateManagerService {
226        crate::services::affiliate_manager::AffiliateManagerService::new(self.clone())
227    }
228
229    /// Typed access to the `agent-studio` API v2 surface.
230    ///
231    /// Requires the `agent-studio` cargo feature.
232    #[cfg(feature = "agent-studio")]
233    #[cfg_attr(docsrs, doc(cfg(feature = "agent-studio")))]
234    pub fn agent_studio(&self) -> crate::services::agent_studio::AgentStudioService {
235        crate::services::agent_studio::AgentStudioService::new(self.clone())
236    }
237
238    /// Typed access to the `associations` API v2 surface.
239    ///
240    /// Requires the `associations` cargo feature.
241    #[cfg(feature = "associations")]
242    #[cfg_attr(docsrs, doc(cfg(feature = "associations")))]
243    pub fn associations(&self) -> crate::services::associations::AssociationsService {
244        crate::services::associations::AssociationsService::new(self.clone())
245    }
246
247    /// Typed access to the `blogs` API v2 surface.
248    ///
249    /// Requires the `blogs` cargo feature.
250    #[cfg(feature = "blogs")]
251    #[cfg_attr(docsrs, doc(cfg(feature = "blogs")))]
252    pub fn blogs(&self) -> crate::services::blogs::BlogsService {
253        crate::services::blogs::BlogsService::new(self.clone())
254    }
255
256    /// Typed access to the `brand-boards` API v2 surface.
257    ///
258    /// Requires the `brand-boards` cargo feature.
259    #[cfg(feature = "brand-boards")]
260    #[cfg_attr(docsrs, doc(cfg(feature = "brand-boards")))]
261    pub fn brand_boards(&self) -> crate::services::brand_boards::BrandBoardsService {
262        crate::services::brand_boards::BrandBoardsService::new(self.clone())
263    }
264
265    /// Typed access to the `businesses` API v2 surface.
266    ///
267    /// Requires the `businesses` cargo feature.
268    #[cfg(feature = "businesses")]
269    #[cfg_attr(docsrs, doc(cfg(feature = "businesses")))]
270    pub fn businesses(&self) -> crate::services::businesses::BusinessesService {
271        crate::services::businesses::BusinessesService::new(self.clone())
272    }
273
274    /// Typed access to the `campaigns` API v2 surface.
275    ///
276    /// Requires the `campaigns` cargo feature.
277    #[cfg(feature = "campaigns")]
278    #[cfg_attr(docsrs, doc(cfg(feature = "campaigns")))]
279    pub fn campaigns(&self) -> crate::services::campaigns::CampaignsService {
280        crate::services::campaigns::CampaignsService::new(self.clone())
281    }
282
283    /// Typed access to the `companies` API v2 surface.
284    ///
285    /// Requires the `companies` cargo feature.
286    #[cfg(feature = "companies")]
287    #[cfg_attr(docsrs, doc(cfg(feature = "companies")))]
288    pub fn companies(&self) -> crate::services::companies::CompaniesService {
289        crate::services::companies::CompaniesService::new(self.clone())
290    }
291
292    /// Typed access to the `conversation-ai` API v2 surface.
293    ///
294    /// Requires the `conversation-ai` cargo feature.
295    #[cfg(feature = "conversation-ai")]
296    #[cfg_attr(docsrs, doc(cfg(feature = "conversation-ai")))]
297    pub fn conversation_ai(&self) -> crate::services::conversation_ai::ConversationAiService {
298        crate::services::conversation_ai::ConversationAiService::new(self.clone())
299    }
300
301    /// Typed access to the `courses` API v2 surface.
302    ///
303    /// Requires the `courses` cargo feature.
304    #[cfg(feature = "courses")]
305    #[cfg_attr(docsrs, doc(cfg(feature = "courses")))]
306    pub fn courses(&self) -> crate::services::courses::CoursesService {
307        crate::services::courses::CoursesService::new(self.clone())
308    }
309
310    /// Typed access to the `custom-fields` API v2 surface.
311    ///
312    /// Requires the `custom-fields` cargo feature.
313    #[cfg(feature = "custom-fields")]
314    #[cfg_attr(docsrs, doc(cfg(feature = "custom-fields")))]
315    pub fn custom_fields(&self) -> crate::services::custom_fields::CustomFieldsService {
316        crate::services::custom_fields::CustomFieldsService::new(self.clone())
317    }
318
319    /// Typed access to the `custom-menus` API v2 surface.
320    ///
321    /// Requires the `custom-menus` cargo feature.
322    #[cfg(feature = "custom-menus")]
323    #[cfg_attr(docsrs, doc(cfg(feature = "custom-menus")))]
324    pub fn custom_menus(&self) -> crate::services::custom_menus::CustomMenusService {
325        crate::services::custom_menus::CustomMenusService::new(self.clone())
326    }
327
328    /// Typed access to the `email-isv` API v2 surface.
329    ///
330    /// Requires the `email-isv` cargo feature.
331    #[cfg(feature = "email-isv")]
332    #[cfg_attr(docsrs, doc(cfg(feature = "email-isv")))]
333    pub fn email_isv(&self) -> crate::services::email_isv::EmailIsvService {
334        crate::services::email_isv::EmailIsvService::new(self.clone())
335    }
336
337    /// Typed access to the `emails` API v2 surface.
338    ///
339    /// Requires the `emails` cargo feature.
340    #[cfg(feature = "emails")]
341    #[cfg_attr(docsrs, doc(cfg(feature = "emails")))]
342    pub fn emails(&self) -> crate::services::emails::EmailsService {
343        crate::services::emails::EmailsService::new(self.clone())
344    }
345
346    /// Typed access to the `forms` API v2 surface.
347    ///
348    /// Requires the `forms` cargo feature.
349    #[cfg(feature = "forms")]
350    #[cfg_attr(docsrs, doc(cfg(feature = "forms")))]
351    pub fn forms(&self) -> crate::services::forms::FormsService {
352        crate::services::forms::FormsService::new(self.clone())
353    }
354
355    /// Typed access to the `funnels` API v2 surface.
356    ///
357    /// Requires the `funnels` cargo feature.
358    #[cfg(feature = "funnels")]
359    #[cfg_attr(docsrs, doc(cfg(feature = "funnels")))]
360    pub fn funnels(&self) -> crate::services::funnels::FunnelsService {
361        crate::services::funnels::FunnelsService::new(self.clone())
362    }
363
364    /// Typed access to the `invoices` API v2 surface.
365    ///
366    /// Requires the `invoices` cargo feature.
367    #[cfg(feature = "invoices")]
368    #[cfg_attr(docsrs, doc(cfg(feature = "invoices")))]
369    pub fn invoices(&self) -> crate::services::invoices::InvoicesService {
370        crate::services::invoices::InvoicesService::new(self.clone())
371    }
372
373    /// Typed access to the `knowledge-base` API v2 surface.
374    ///
375    /// Requires the `knowledge-base` cargo feature.
376    #[cfg(feature = "knowledge-base")]
377    #[cfg_attr(docsrs, doc(cfg(feature = "knowledge-base")))]
378    pub fn knowledge_base(&self) -> crate::services::knowledge_base::KnowledgeBaseService {
379        crate::services::knowledge_base::KnowledgeBaseService::new(self.clone())
380    }
381
382    /// Typed access to the `links` API v2 surface.
383    ///
384    /// Requires the `links` cargo feature.
385    #[cfg(feature = "links")]
386    #[cfg_attr(docsrs, doc(cfg(feature = "links")))]
387    pub fn links(&self) -> crate::services::links::LinksService {
388        crate::services::links::LinksService::new(self.clone())
389    }
390
391    /// Typed access to the `marketplace` API v2 surface.
392    ///
393    /// Requires the `marketplace` cargo feature.
394    #[cfg(feature = "marketplace")]
395    #[cfg_attr(docsrs, doc(cfg(feature = "marketplace")))]
396    pub fn marketplace(&self) -> crate::services::marketplace::MarketplaceService {
397        crate::services::marketplace::MarketplaceService::new(self.clone())
398    }
399
400    /// Typed access to the `medias` API v2 surface.
401    ///
402    /// Requires the `medias` cargo feature.
403    #[cfg(feature = "medias")]
404    #[cfg_attr(docsrs, doc(cfg(feature = "medias")))]
405    pub fn medias(&self) -> crate::services::medias::MediasService {
406        crate::services::medias::MediasService::new(self.clone())
407    }
408
409    /// Typed access to the `oauth` API v2 surface.
410    ///
411    /// Requires the `oauth` cargo feature.
412    #[cfg(feature = "oauth")]
413    #[cfg_attr(docsrs, doc(cfg(feature = "oauth")))]
414    pub fn oauth(&self) -> crate::services::oauth::OauthService {
415        crate::services::oauth::OauthService::new(self.clone())
416    }
417
418    /// Typed access to the `objects` API v2 surface.
419    ///
420    /// Requires the `objects` cargo feature.
421    #[cfg(feature = "objects")]
422    #[cfg_attr(docsrs, doc(cfg(feature = "objects")))]
423    pub fn objects(&self) -> crate::services::objects::ObjectsService {
424        crate::services::objects::ObjectsService::new(self.clone())
425    }
426
427    /// Typed access to the `payments` API v2 surface.
428    ///
429    /// Requires the `payments` cargo feature.
430    #[cfg(feature = "payments")]
431    #[cfg_attr(docsrs, doc(cfg(feature = "payments")))]
432    pub fn payments(&self) -> crate::services::payments::PaymentsService {
433        crate::services::payments::PaymentsService::new(self.clone())
434    }
435
436    /// Typed access to the `phone-system` API v2 surface.
437    ///
438    /// Requires the `phone-system` cargo feature.
439    #[cfg(feature = "phone-system")]
440    #[cfg_attr(docsrs, doc(cfg(feature = "phone-system")))]
441    pub fn phone_system(&self) -> crate::services::phone_system::PhoneSystemService {
442        crate::services::phone_system::PhoneSystemService::new(self.clone())
443    }
444
445    /// Typed access to the `products` API v2 surface.
446    ///
447    /// Requires the `products` cargo feature.
448    #[cfg(feature = "products")]
449    #[cfg_attr(docsrs, doc(cfg(feature = "products")))]
450    pub fn products(&self) -> crate::services::products::ProductsService {
451        crate::services::products::ProductsService::new(self.clone())
452    }
453
454    /// Typed access to the `proposals` API v2 surface.
455    ///
456    /// Requires the `proposals` cargo feature.
457    #[cfg(feature = "proposals")]
458    #[cfg_attr(docsrs, doc(cfg(feature = "proposals")))]
459    pub fn proposals(&self) -> crate::services::proposals::ProposalsService {
460        crate::services::proposals::ProposalsService::new(self.clone())
461    }
462
463    /// Typed access to the `saas-api` API v2 surface.
464    ///
465    /// Requires the `saas-api` cargo feature.
466    #[cfg(feature = "saas-api")]
467    #[cfg_attr(docsrs, doc(cfg(feature = "saas-api")))]
468    pub fn saas_api(&self) -> crate::services::saas_api::SaasApiService {
469        crate::services::saas_api::SaasApiService::new(self.clone())
470    }
471
472    /// Typed access to the `snapshots` API v2 surface.
473    ///
474    /// Requires the `snapshots` cargo feature.
475    #[cfg(feature = "snapshots")]
476    #[cfg_attr(docsrs, doc(cfg(feature = "snapshots")))]
477    pub fn snapshots(&self) -> crate::services::snapshots::SnapshotsService {
478        crate::services::snapshots::SnapshotsService::new(self.clone())
479    }
480
481    /// Typed access to the `social-media-posting` API v2 surface.
482    ///
483    /// Requires the `social-media-posting` cargo feature.
484    #[cfg(feature = "social-media-posting")]
485    #[cfg_attr(docsrs, doc(cfg(feature = "social-media-posting")))]
486    pub fn social_media_posting(
487        &self,
488    ) -> crate::services::social_media_posting::SocialMediaPostingService {
489        crate::services::social_media_posting::SocialMediaPostingService::new(self.clone())
490    }
491
492    /// Typed access to the `store` API v2 surface.
493    ///
494    /// Requires the `store` cargo feature.
495    #[cfg(feature = "store")]
496    #[cfg_attr(docsrs, doc(cfg(feature = "store")))]
497    pub fn store(&self) -> crate::services::store::StoreService {
498        crate::services::store::StoreService::new(self.clone())
499    }
500
501    /// Typed access to the `surveys` API v2 surface.
502    ///
503    /// Requires the `surveys` cargo feature.
504    #[cfg(feature = "surveys")]
505    #[cfg_attr(docsrs, doc(cfg(feature = "surveys")))]
506    pub fn surveys(&self) -> crate::services::surveys::SurveysService {
507        crate::services::surveys::SurveysService::new(self.clone())
508    }
509
510    /// Typed access to the `users` API v2 surface.
511    ///
512    /// Requires the `users` cargo feature.
513    #[cfg(feature = "users")]
514    #[cfg_attr(docsrs, doc(cfg(feature = "users")))]
515    pub fn users(&self) -> crate::services::users::UsersService {
516        crate::services::users::UsersService::new(self.clone())
517    }
518
519    /// Typed access to the `voice-ai` API v2 surface.
520    ///
521    /// Requires the `voice-ai` cargo feature.
522    #[cfg(feature = "voice-ai")]
523    #[cfg_attr(docsrs, doc(cfg(feature = "voice-ai")))]
524    pub fn voice_ai(&self) -> crate::services::voice_ai::VoiceAiService {
525        crate::services::voice_ai::VoiceAiService::new(self.clone())
526    }
527
528    /// Typed access to the `workflows` API v2 surface.
529    ///
530    /// Requires the `workflows` cargo feature.
531    #[cfg(feature = "workflows")]
532    #[cfg_attr(docsrs, doc(cfg(feature = "workflows")))]
533    pub fn workflows(&self) -> crate::services::workflows::WorkflowsService {
534        crate::services::workflows::WorkflowsService::new(self.clone())
535    }
536
537    /// The **API v3** services.
538    ///
539    /// v3 is a parallel, newer surface: 627 operations, including modules that
540    /// only exist there (`ad-publishing`, `social-planner`, `saas`,
541    /// `chat-widget`). Calls made through it send `Version: v3`.
542    ///
543    /// ```ignore
544    /// let dup = ghl.v3().contacts().get_duplicate_contact(&params).await?;
545    /// ```
546    pub fn v3(&self) -> crate::services::v3::V3 {
547        crate::services::v3::V3 {
548            client: self.clone(),
549        }
550    }
551    /// The most recently observed rate-limit headroom.
552    pub fn rate_status(&self) -> RateStatus {
553        let read = |a: &AtomicI64| {
554            let v = a.load(Ordering::Relaxed);
555            (v >= 0).then_some(v)
556        };
557        RateStatus {
558            burst_remaining: read(&self.inner.rate_remaining),
559            daily_remaining: read(&self.inner.rate_daily_remaining),
560        }
561    }
562
563    /// Exchange an **agency (Company) token** for a **location token**
564    /// (`POST /oauth/locationToken`) and return a new client scoped to that location.
565    ///
566    /// This is the multi-tenant primitive: one agency credential, many sub-accounts.
567    pub async fn as_location(&self, company_id: &str, location_id: &str) -> Result<Ghl> {
568        #[derive(serde::Deserialize)]
569        struct LocationTokenResponse {
570            access_token: String,
571        }
572
573        let bearer = self
574            .inner
575            .auth
576            .bearer(&self.inner.http, &self.inner.base_url)
577            .await?;
578        let response = self
579            .inner
580            .http
581            .post(format!("{}/oauth/locationToken", self.inner.base_url))
582            .header(AUTHORIZATION, format!("Bearer {bearer}"))
583            .header("Version", API_VERSION)
584            .form(&[("companyId", company_id), ("locationId", location_id)])
585            .send()
586            .await?;
587
588        let status = response.status();
589        if !status.is_success() {
590            let body = response.text().await.unwrap_or_default();
591            return Err(Error::Auth(format!(
592                "location token exchange failed ({status}): {body}"
593            )));
594        }
595        let parsed: LocationTokenResponse = response
596            .json()
597            .await
598            .map_err(|e| Error::Auth(format!("unexpected locationToken response: {e}")))?;
599
600        Ghl::builder()
601            .base_url(&self.inner.base_url)
602            .access_token(parsed.access_token)
603            .max_retries(self.inner.max_retries)
604            .build()
605    }
606
607    // ----- raw escape hatches (for endpoints not yet typed) -----
608
609    /// `GET` any API path and return the raw JSON.
610    pub async fn get_raw(&self, path: &str, query: &[(&str, &str)]) -> Result<serde_json::Value> {
611        let query: Vec<(String, String)> = query
612            .iter()
613            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
614            .collect();
615        self.send(Method::GET, path, &query, None::<&()>).await
616    }
617
618    /// `POST` any API path with a JSON body and return the raw JSON.
619    pub async fn post_raw(&self, path: &str, body: &impl Serialize) -> Result<serde_json::Value> {
620        self.send(Method::POST, path, &[], Some(body)).await
621    }
622
623    /// Call any API endpoint with an arbitrary method, query, and body.
624    ///
625    /// This is the fully generic escape hatch. Prefer the typed module services
626    /// when they cover what you need — they validate inputs and return real
627    /// types. `version` overrides the `Version` header for the endpoints that
628    /// require a value other than [`API_VERSION`].
629    pub async fn request_raw(
630        &self,
631        method: &str,
632        path: &str,
633        query: &[(String, String)],
634        body: Option<&serde_json::Value>,
635        version: Option<&str>,
636    ) -> Result<serde_json::Value> {
637        let method = Method::from_bytes(method.to_uppercase().as_bytes())
638            .map_err(|_| Error::Config(format!("invalid HTTP method `{method}`")))?;
639        self.send_versioned(method, path, query, body, version)
640            .await
641    }
642
643    // ----- request core -----
644
645    pub(crate) async fn send<T: DeserializeOwned>(
646        &self,
647        method: Method,
648        path: &str,
649        query: &[(String, String)],
650        body: Option<&impl Serialize>,
651    ) -> Result<T> {
652        self.send_versioned(method, path, query, body, None).await
653    }
654
655    pub(crate) async fn send_versioned<T: DeserializeOwned>(
656        &self,
657        method: Method,
658        path: &str,
659        query: &[(String, String)],
660        body: Option<&impl Serialize>,
661        version: Option<&str>,
662    ) -> Result<T> {
663        // Serialize the body once so retries don't re-serialize (and can't observe drift).
664        let body = match body {
665            Some(b) => Some(serde_json::to_value(b).map_err(|source| Error::Decode {
666                endpoint: path.to_owned(),
667                source,
668            })?),
669            None => None,
670        };
671
672        let url = format!("{}{}", self.inner.base_url, path);
673        let idempotent = matches!(
674            method,
675            Method::GET | Method::PUT | Method::DELETE | Method::HEAD
676        );
677        let mut attempt: u32 = 0;
678
679        loop {
680            let bearer = self
681                .inner
682                .auth
683                .bearer(&self.inner.http, &self.inner.base_url)
684                .await?;
685
686            let mut request = self
687                .inner
688                .http
689                .request(method.clone(), &url)
690                .header(AUTHORIZATION, format!("Bearer {bearer}"))
691                .header("Version", version.unwrap_or(API_VERSION))
692                .header(reqwest::header::ACCEPT, "application/json");
693            if !query.is_empty() {
694                request = request.query(query);
695            }
696            if let Some(ref b) = body {
697                request = request.json(b);
698            }
699
700            let outcome = request.send().await;
701
702            match outcome {
703                Ok(response) => {
704                    self.record_rate_headers(response.headers());
705                    let status = response.status();
706
707                    if status.is_success() {
708                        let bytes = response.bytes().await?;
709                        return serde_json::from_slice(&bytes).map_err(|source| Error::Decode {
710                            endpoint: path.to_owned(),
711                            source,
712                        });
713                    }
714
715                    let retry_after = parse_retry_after(response.headers());
716                    let request_id = response
717                        .headers()
718                        .get("x-request-id")
719                        .and_then(|v| v.to_str().ok())
720                        .map(str::to_owned);
721                    let message = read_api_message(response).await;
722
723                    let retryable = status == StatusCode::TOO_MANY_REQUESTS
724                        || (idempotent && status.is_server_error());
725                    if retryable && attempt < self.inner.max_retries {
726                        let delay = retry_after.unwrap_or_else(|| backoff_delay(attempt));
727                        tracing::warn!(
728                            %status, attempt, delay_ms = delay.as_millis() as u64, path,
729                            "GoHighLevel request failed; retrying"
730                        );
731                        tokio::time::sleep(delay).await;
732                        attempt += 1;
733                        continue;
734                    }
735
736                    return Err(if status == StatusCode::TOO_MANY_REQUESTS {
737                        Error::RateLimited { retry_after }
738                    } else {
739                        Error::Api {
740                            status,
741                            message,
742                            request_id,
743                        }
744                    });
745                }
746                Err(err) => {
747                    // Connection-level failures: retry idempotent requests only.
748                    if idempotent && attempt < self.inner.max_retries && err.status().is_none() {
749                        let delay = backoff_delay(attempt);
750                        tracing::warn!(
751                            error = %err, attempt, delay_ms = delay.as_millis() as u64, path,
752                            "transport error; retrying"
753                        );
754                        tokio::time::sleep(delay).await;
755                        attempt += 1;
756                        continue;
757                    }
758                    return Err(err.into());
759                }
760            }
761        }
762    }
763
764    fn record_rate_headers(&self, headers: &HeaderMap) {
765        let parse =
766            |name: &str| -> Option<i64> { headers.get(name)?.to_str().ok()?.trim().parse().ok() };
767        if let Some(v) = parse("x-ratelimit-remaining") {
768            self.inner.rate_remaining.store(v, Ordering::Relaxed);
769        }
770        if let Some(v) = parse("x-ratelimit-daily-remaining") {
771            self.inner.rate_daily_remaining.store(v, Ordering::Relaxed);
772        }
773    }
774}
775
776/// Exponential backoff with full jitter, capped.
777fn backoff_delay(attempt: u32) -> Duration {
778    let exp = BACKOFF_BASE.saturating_mul(2u32.saturating_pow(attempt));
779    let cap = exp.min(BACKOFF_CAP);
780    cap.mul_f64(0.5 + fastrand::f64() * 0.5)
781}
782
783fn parse_retry_after(headers: &HeaderMap) -> Option<Duration> {
784    let value: &HeaderValue = headers.get(reqwest::header::RETRY_AFTER)?;
785    let seconds: u64 = value.to_str().ok()?.trim().parse().ok()?;
786    Some(Duration::from_secs(seconds))
787}
788
789/// Pull a human-readable message out of a GoHighLevel error body.
790/// Bodies are typically `{"message": "…"}` or `{"message": ["…", "…"]}`.
791async fn read_api_message(response: reqwest::Response) -> String {
792    let text = response.text().await.unwrap_or_default();
793    match serde_json::from_str::<serde_json::Value>(&text) {
794        Ok(v) => match v.get("message") {
795            Some(serde_json::Value::String(s)) => s.clone(),
796            Some(serde_json::Value::Array(parts)) => parts
797                .iter()
798                .filter_map(|p| p.as_str())
799                .collect::<Vec<_>>()
800                .join("; "),
801            _ => text,
802        },
803        Err(_) => text,
804    }
805}