diff --git a/python/sample-app-mongo/app.py b/python/sample-app-mongo/app.py
index ebd0e28..0d08011 100644
--- a/python/sample-app-mongo/app.py
+++ b/python/sample-app-mongo/app.py
@@ -1,7 +1,7 @@
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, HTTPException, Form, Depends
-from fastapi.responses import HTMLResponse, RedirectResponse
+from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from dotenv import load_dotenv
@@ -18,8 +18,8 @@
# Configuration
PORT = int(os.getenv('PORT', '3003'))
-CLIENT_ID = os.getenv('CLIENT_ID', '67f4fa36f25eeb6dc2636d6c-mdr0vmth')
-CLIENT_SECRET = os.getenv('CLIENT_SECRET', '6fede484-bfc8-4210-994f-58d2a94974dc')
+CLIENT_ID = os.getenv('CLIENT_ID')
+CLIENT_SECRET = os.getenv('CLIENT_SECRET')
# Global app state
class AppState:
@@ -98,10 +98,12 @@ async def index(request: Request):
@app.get('/install')
async def install(ghl: HighLevel = Depends(get_ghl)):
redirect_uri = f"http://localhost:{PORT}/oauth-callback"
+ # oauth.readonly/oauth.write are needed to list installed locations and mint
+ # location tokens when the app is installed at the agency (company) level.
authorization_url = ghl.oauth.get_authorization_url(
CLIENT_ID,
redirect_uri,
- 'contacts.readonly contacts.write'
+ 'contacts.readonly contacts.write oauth.readonly oauth.write'
)
print('Redirect URL:', authorization_url)
return RedirectResponse(url=authorization_url, status_code=302)
@@ -114,24 +116,109 @@ async def oauth_callback(request: Request, code: str = None, ghl: HighLevel = De
try:
access_token_data = await ghl.oauth.get_access_token({
- 'client_id': CLIENT_ID,
- 'client_secret': CLIENT_SECRET,
+ 'clientId': CLIENT_ID,
+ 'clientSecret': CLIENT_SECRET,
'code': code,
- 'grant_type': 'authorization_code',
+ 'grantType': 'authorization_code',
})
print('Token:', access_token_data)
- await ghl.get_session_storage().set_session(access_token_data['locationId'], access_token_data)
- return templates.TemplateResponse('token.html', {
- 'request': request,
- 'token': access_token_data,
- 'location_id': access_token_data['locationId']
+ location_id = access_token_data.get('locationId')
+ if location_id:
+ await ghl.get_session_storage().set_session(location_id, access_token_data)
+ return templates.TemplateResponse('token.html', {
+ 'request': request,
+ 'token': access_token_data,
+ 'location_id': location_id
+ })
+
+ # Company (agency) level install: store the company token and poll for a
+ # location token via the loading page.
+ company_id = access_token_data.get('companyId')
+ if not company_id:
+ return RedirectResponse(url='/error-page?msg=Token response had neither locationId nor companyId', status_code=302)
+
+ await ghl.get_session_storage().set_session(company_id, access_token_data)
+ # Make the agency (company) token available to the agency-scoped polling calls
+ # directly via config (checked before storage). Cleared once a location resolves.
+ ghl.update_config({
+ 'agency_access_token': access_token_data.get('accessToken') or access_token_data.get('access_token')
})
+ return templates.TemplateResponse('loading.html', {'request': request, 'company_id': company_id})
except Exception as err:
print('Error fetching token:', err)
traceback.print_exc()
return RedirectResponse(url=f'/error-page?msg=Error fetching token: {str(err)}', status_code=302)
+@app.get('/install-locations')
+async def install_locations(companyId: str = None, ghl: HighLevel = Depends(get_ghl)):
+ """Poll endpoint: resolve a location token from the company token (JSON)."""
+ if not companyId:
+ return JSONResponse({'ready': False, 'error': 'No companyId provided'})
+
+ try:
+ app_id = (CLIENT_ID or '').split('-')[0]
+ installed = await ghl.oauth.get_installed_location(
+ company_id=companyId,
+ app_id=app_id,
+ is_installed=True,
+ options={'headers': {'companyId': companyId}}
+ )
+ items = installed.get('items', []) if isinstance(installed, dict) else []
+
+ resolved_location_id = None
+ for item in items:
+ location_id = item.get('_id')
+ if not location_id:
+ continue
+
+ existing = await ghl.get_session_storage().get_session(location_id)
+ if not existing:
+ location_token = await ghl.oauth.get_location_access_token(
+ request_body={'companyId': companyId, 'locationId': location_id},
+ options={'headers': {'companyId': companyId}}
+ )
+ # The location-token response is camelCase; normalize the keys the
+ # SDK reads (access_token / refresh_token) before storing.
+ await ghl.get_session_storage().set_session(location_id, {
+ **location_token,
+ 'access_token': location_token.get('accessToken'),
+ 'refresh_token': location_token.get('refreshToken'),
+ 'companyId': companyId,
+ 'locationId': location_id,
+ 'userType': 'Location',
+ })
+
+ resolved_location_id = resolved_location_id or location_id
+
+ if resolved_location_id:
+ # Stop using the agency token now that a location token is stored, so
+ # subsequent location-scoped calls (e.g. /contact) use the location token.
+ ghl.update_config({'agency_access_token': None})
+ return JSONResponse({'ready': True, 'locationId': resolved_location_id})
+ return JSONResponse({'ready': False})
+ except Exception as err:
+ print('Error resolving location token:', err)
+ traceback.print_exc()
+ return JSONResponse({'ready': False, 'error': str(err)})
+
+@app.get('/oauth-result', response_class=HTMLResponse)
+async def oauth_result(request: Request, companyId: str = None, locationId: str = None, ghl: HighLevel = Depends(get_ghl)):
+ """Show the resolved location token after the loading/polling step."""
+ token = None
+ if locationId:
+ token = await ghl.get_session_storage().get_session(locationId)
+ if not token and companyId:
+ token = await ghl.get_session_storage().get_session(companyId)
+
+ if not token:
+ return RedirectResponse(url='/error-page?msg=No session found for the resolved location', status_code=302)
+ return templates.TemplateResponse('token.html', {
+ 'request': request,
+ 'token': token,
+ 'location_id': locationId
+ })
+
@app.get('/contact', response_class=HTMLResponse)
async def contact(request: Request, resourceId: str = None, ghl: HighLevel = Depends(get_ghl)):
"""Handle contact retrieval"""
@@ -144,18 +231,21 @@ async def contact(request: Request, resourceId: str = None, ghl: HighLevel = Dep
if not authorized:
return RedirectResponse(url=f'/error-page?msg=Please authorize the application to proceed', status_code=302)
- contacts_data = await ghl.contacts.get_contacts(resourceId, None, None, None, 5)
- print('Fetched contacts:', contacts_data['contacts'])
+ search_result = await ghl.contacts.search_contacts_advanced(
+ request_body={'locationId': resourceId, 'pageLimit': 5},
+ options={'headers': {'locationId': resourceId}}
+ )
+ contacts = search_result.get('contacts', []) if isinstance(search_result, dict) else []
+ print('Fetched contacts:', contacts)
- contacts = contacts_data.get('contacts', [])
if not contacts:
return RedirectResponse(url=f'/error-page?msg=No contacts found', status_code=302)
contact_id = contacts[0]['id']
- if not contact_id:
- return RedirectResponse(url=f'/error-page?msg=No contact found', status_code=302)
-
- contact_data = await ghl.contacts.get_contact(contact_id, {'headers': {'locationId': resourceId}})
+ contact_data = await ghl.contacts.get_contact(
+ contact_id,
+ options={'headers': {'locationId': resourceId}}
+ )
return templates.TemplateResponse('contact.html', {
'request': request,
'contact': contact_data.get('contact')
@@ -182,7 +272,7 @@ async def refresh_token(request: Request, resourceId: str = None, ghl: HighLevel
CLIENT_ID,
CLIENT_SECRET,
'refresh_token',
- token_details.get('user_type', 'Location')
+ token_details.get('userType', 'Location')
)
await ghl.get_session_storage().set_session(resourceId, refreshed_token)
return templates.TemplateResponse('token.html', {
diff --git a/python/sample-app-mongo/requirements.txt b/python/sample-app-mongo/requirements.txt
index 43e009c..5be6b6f 100644
--- a/python/sample-app-mongo/requirements.txt
+++ b/python/sample-app-mongo/requirements.txt
@@ -3,4 +3,4 @@ uvicorn>=0.24.0
jinja2>=3.0.0
python-multipart>=0.0.6
python-dotenv>=0.19.0
-gohighlevel-api-client==1.0.0b1
+gohighlevel-api-client>=3.0.0
diff --git a/python/sample-app-mongo/templates/loading.html b/python/sample-app-mongo/templates/loading.html
new file mode 100644
index 0000000..44f0612
--- /dev/null
+++ b/python/sample-app-mongo/templates/loading.html
@@ -0,0 +1,51 @@
+
+
+
+
+
+ Finishing setupโฆ - GHL OAuth Example
+
+
+
+
+
+
+
diff --git a/python/sample-app-mongo/templates/token.html b/python/sample-app-mongo/templates/token.html
index c7c2938..44b7df3 100644
--- a/python/sample-app-mongo/templates/token.html
+++ b/python/sample-app-mongo/templates/token.html
@@ -128,17 +128,17 @@
{% if token %}
- {% if token.access_token %}
+ {% if token.accessToken %}
Access Token
-
{{ token.access_token }}
+
{{ token.accessToken }}
{% endif %}
- {% if token.refresh_token %}
+ {% if token.refreshToken %}
Refresh Token
-
{{ token.refresh_token }}
+
{{ token.refreshToken }}
{% endif %}
@@ -164,9 +164,10 @@
{% endif %}
+ {% set resolved_location_id = location_id or token.locationId %}
{% else %}
diff --git a/python/sample-app-oauth/app.py b/python/sample-app-oauth/app.py
index 8ba7ccd..fdbd22f 100644
--- a/python/sample-app-oauth/app.py
+++ b/python/sample-app-oauth/app.py
@@ -2,10 +2,11 @@
import asyncio
import threading
from concurrent.futures import ThreadPoolExecutor
-from flask import Flask, render_template, request, redirect, url_for
+from flask import Flask, render_template, request, redirect, url_for, jsonify
from dotenv import load_dotenv
from highlevel import HighLevel
import traceback
+import inspect
# Create a dedicated thread and event loop for async operations
executor = ThreadPoolExecutor(max_workers=1)
@@ -50,6 +51,8 @@ def run_async_in_loop(coro):
)
app = Flask(__name__)
+# Treat trailing-slash URLs (e.g. /install/) the same as /install.
+app.url_map.strict_slashes = False
def check_env():
"""Middleware to check environment variables"""
@@ -86,7 +89,7 @@ def install():
authorization_url = ghl.oauth.get_authorization_url(
CLIENT_ID,
redirect_uri,
- 'contacts.readonly contacts.write'
+ ''
)
print('Redirect URL:', authorization_url)
return redirect(authorization_url)
@@ -100,24 +103,124 @@ def oauth_callback():
async def async_oauth_operations():
access_token_data = await ghl.oauth.get_access_token({
- 'client_id': CLIENT_ID,
- 'client_secret': CLIENT_SECRET,
+ 'clientId': CLIENT_ID,
+ 'clientSecret': CLIENT_SECRET,
'code': code,
- 'grant_type': 'authorization_code',
+ 'grantType': 'authorization_code',
})
print('Token:', access_token_data)
-
- await ghl.get_session_storage().set_session(access_token_data['locationId'], access_token_data)
return access_token_data
try:
access_token_data = run_async_in_loop(async_oauth_operations())
- return render_template('token.html', token=access_token_data, location_id=access_token_data['locationId'])
+ location_id = access_token_data.get('locationId')
+
+ if location_id:
+ # Location-level install: store under the location id and show the token.
+ run_async_in_loop(
+ ghl.get_session_storage().set_session(location_id, access_token_data)
+ )
+ return render_template('token.html', token=access_token_data, location_id=location_id)
+
+ # Company (agency) level install: no location id yet. Store the company
+ # token, then show a loading page that polls until a location token is ready.
+ company_id = access_token_data.get('companyId')
+ if not company_id:
+ return redirect(url_for('error_page', msg='Token response had neither locationId nor companyId'))
+
+ run_async_in_loop(
+ ghl.get_session_storage().set_session(company_id, access_token_data)
+ )
+ # Make the agency (company) token available to the agency-scoped polling calls
+ # (get_installed_location / get_location_access_token) directly via config โ
+ # get_token_for_security checks config before storage, so this avoids a
+ # cross-request storage round-trip. Cleared once a location token resolves.
+ ghl.update_config({
+ 'agency_access_token': access_token_data.get('accessToken') or access_token_data.get('access_token')
+ })
+ return render_template('loading.html', company_id=company_id)
except Exception as err:
print('Error fetching token:', err)
traceback.print_exc()
return redirect(url_for('error_page', msg=f'Error fetching token: {str(err)}'))
+@app.route('/install-locations')
+def install_locations():
+ """Poll endpoint: resolve a location token from the company token (JSON)."""
+ company_id = request.args.get('companyId')
+ if not company_id:
+ return jsonify({'ready': False, 'error': 'No companyId provided'})
+
+ async def resolve_location():
+ app_id = (CLIENT_ID or '').split('-')[0]
+ installed = await ghl.oauth.get_installed_location(
+ company_id=company_id,
+ app_id=app_id,
+ is_installed=True,
+ options={'headers': {'companyId': company_id}}
+ )
+ items = installed.get('items', []) if isinstance(installed, dict) else []
+
+ resolved_location_id = None
+ for item in items:
+ location_id = item.get('_id')
+ if not location_id:
+ continue
+
+ existing = await ghl.get_session_storage().get_session(location_id)
+ if not existing:
+ location_token = await ghl.oauth.get_location_access_token(
+ request_body={'companyId': company_id, 'locationId': location_id},
+ options={'headers': {'companyId': company_id}}
+ )
+ # The location-token response is camelCase; normalize the token keys
+ # the SDK reads (access_token / refresh_token) before storing.
+ await ghl.get_session_storage().set_session(location_id, {
+ **location_token,
+ 'access_token': location_token.get('accessToken'),
+ 'refresh_token': location_token.get('refreshToken'),
+ 'companyId': company_id,
+ 'locationId': location_id,
+ 'userType': 'Location',
+ })
+
+ resolved_location_id = resolved_location_id or location_id
+
+ if resolved_location_id:
+ return {'ready': True, 'locationId': resolved_location_id}
+ return {'ready': False}
+
+ try:
+ result = run_async_in_loop(resolve_location())
+ if result.get('ready'):
+ # Location token resolved & stored; stop using the agency token so
+ # subsequent location-scoped calls (e.g. /contact) use the location token.
+ ghl.update_config({'agency_access_token': None})
+ return jsonify(result)
+ except Exception as err:
+ print('Error resolving location token:', err)
+ traceback.print_exc()
+ return jsonify({'ready': False, 'error': str(err)})
+
+@app.route('/oauth-result')
+def oauth_result():
+ """Show the resolved location token after the loading/polling step."""
+ company_id = request.args.get('companyId')
+ location_id = request.args.get('locationId')
+
+ async def load_session():
+ token = None
+ if location_id:
+ token = await ghl.get_session_storage().get_session(location_id)
+ if not token and company_id:
+ token = await ghl.get_session_storage().get_session(company_id)
+ return token
+
+ token = run_async_in_loop(load_session())
+ if not token:
+ return redirect(url_for('error_page', msg='No session found for the resolved location'))
+ return render_template('token.html', token=token, location_id=location_id)
+
@app.route('/contact')
def contact():
"""Handle contact retrieval - run async operation in event loop"""
@@ -133,14 +236,21 @@ async def async_contact_operations():
if not authorized:
return {'error': 'Please authorize the application to proceed'}
- contacts_data = await ghl.contacts.get_contacts(resource_id, None, None, None, 5)
- print('Fetched contacts:', contacts_data['contacts'])
+ search_result = await ghl.contacts.search_contacts_advanced(
+ request_body={'locationId': resource_id, 'pageLimit': 5},
+ options={'headers': {'locationId': resource_id}}
+ )
+ contacts = search_result.get('contacts', []) if isinstance(search_result, dict) else []
+ print('Fetched contacts:', contacts)
- contact_id = contacts_data['contacts'][0]['id']
- if not contact_id:
+ if not contacts:
return {'error': 'No contact found'}
- contact_data = await ghl.contacts.get_contact(contact_id, { 'headers': { 'locationId': resource_id } })
+ contact_id = contacts[0]['id']
+ contact_data = await ghl.contacts.get_contact(
+ contact_id,
+ options={'headers': {'locationId': resource_id}}
+ )
update_data = {
'firstName': 'Tony updated'
@@ -183,7 +293,7 @@ async def async_refresh_operations():
CLIENT_ID,
CLIENT_SECRET,
'refresh_token',
- token_details.get('user_type', 'Location')
+ token_details.get('userType', 'Location')
)
await ghl.get_session_storage().set_session(resource_id, refreshed_token)
return refreshed_token
@@ -205,5 +315,125 @@ def error_page():
error_msg = request.args.get('msg', 'Unknown error')
return render_template('error.html', error=error_msg)
+# One read-only call per service for a locationId โ a quick end-to-end check that
+# the whole v3 SDK works. (label, service attribute, method name)
+SERVICE_CALLS = [
+ ("Contacts", "contacts", "search_contacts_advanced"),
+ ("Calendars", "calendars", "get_calendars"),
+ ("Campaigns", "campaigns", "get_campaigns"),
+ ("Conversations", "conversations", "search_conversation"),
+ ("Opportunities (Pipelines)", "opportunities", "get_pipelines"),
+ ("Forms", "forms", "get_forms"),
+ ("Funnels", "funnels", "get_funnels"),
+ ("Links", "links", "get_links"),
+ ("Location", "locations", "get_location"),
+ ("Businesses", "businesses", "get_businesses_by_location"),
+ ("Products", "products", "list_invoices"),
+ ("Surveys", "surveys", "get_surveys"),
+ ("Workflows", "workflows", "get_workflow"),
+ ("Emails", "emails", "list_email_campaigns"),
+ ("Brand Voices", "brand_boards", "list_brand_voices"),
+ ("Affiliates", "affiliate_manager", "list_affiliates"),
+ ("Payments (Config)", "payments", "fetch_config"),
+ ("Phone System", "phone_system", "get_number_pool_list"),
+ ("Proposals", "proposals", "list_documents_contracts"),
+ ("Social Planner", "social_planner", "fetch_available_categories"),
+ ("Knowledge Base", "knowledge_base", "list_all_knowledge_bases_paginated"),
+ ("Custom Objects", "objects", "get_object_by_location_id"),
+ ("Voice AI (Call Logs)", "voice_ai", "get_call_logs"),
+ ("Custom Menus", "custom_menus", "get_custom_menus"),
+]
+
+
+def summarize_record(data):
+ """Pull the first record out of an arbitrary SDK response and return a small
+ list of (key, value) pairs to display, or None when there is no data."""
+ record = None
+ if isinstance(data, list):
+ record = data[0] if data else None
+ elif isinstance(data, dict):
+ # 1) a collection under some key -> first item
+ for value in data.values():
+ if isinstance(value, list) and value:
+ record = value[0]
+ break
+ # 2) the dict itself carries scalar fields -> use it
+ if record is None and any(
+ isinstance(v, (str, int, float, bool)) and v != "" for v in data.values()
+ ):
+ record = data
+ # 3) otherwise a nested object under some key
+ if record is None:
+ for value in data.values():
+ if isinstance(value, dict) and value:
+ record = value
+ break
+
+ if not isinstance(record, dict) or not record:
+ return None
+
+ priority = ["id", "_id", "name", "firstName", "lastName", "email", "title",
+ "type", "status", "locationId", "phone", "timezone", "dateAdded"]
+ pairs = []
+ for key in priority:
+ value = record.get(key)
+ if isinstance(value, (str, int, float, bool)) and value != "":
+ pairs.append((key, value))
+ if not pairs:
+ for key, value in record.items():
+ if isinstance(value, (str, int, float, bool)) and value != "":
+ pairs.append((key, value))
+ if len(pairs) >= 5:
+ break
+ return pairs or None
+
+
+async def invoke_service_method(method, location_id):
+ """Call a read-only service method, supplying location_id / request_body / options
+ based on what the method actually accepts."""
+ params = inspect.signature(method).parameters
+ kwargs = {}
+ if "location_id" in params:
+ kwargs["location_id"] = location_id
+ if "request_body" in params:
+ kwargs["request_body"] = {"locationId": location_id, "pageLimit": 5}
+ if "options" in params:
+ kwargs["options"] = {"headers": {"locationId": location_id}}
+ return await method(**kwargs)
+
+
+@app.route('/show-data')
+def show_data():
+ """Call one read-only method from each service for the locationId and render a
+ section per service โ a quick end-to-end verification that the SDK works."""
+ resource_id = request.args.get('resourceId')
+ if not resource_id:
+ return redirect(url_for('error_page', msg='No resourceId provided'))
+
+ async def gather_all():
+ async def call(label, service_attr, method_name):
+ service = getattr(ghl, service_attr, None)
+ method = getattr(service, method_name, None) if service else None
+ if method is None:
+ return {"label": label, "fields": None, "error": "method not available in SDK"}
+ try:
+ data = await invoke_service_method(method, resource_id)
+ return {"label": label, "fields": summarize_record(data), "error": None}
+ except Exception as error:
+ return {"label": label, "fields": None, "error": str(error)}
+
+ return await asyncio.gather(
+ *(call(label, attr, name) for (label, attr, name) in SERVICE_CALLS)
+ )
+
+ try:
+ sections = run_async_in_loop(gather_all())
+ except Exception as error:
+ print('Error building show-data:', error)
+ traceback.print_exc()
+ return redirect(url_for('error_page', msg=f'Error fetching data: {str(error)}'))
+
+ return render_template('show_data.html', sections=sections, location_id=resource_id)
+
if __name__ == '__main__':
app.run(host='0.0.0.0', port=PORT, debug=True)
diff --git a/python/sample-app-oauth/requirements.txt b/python/sample-app-oauth/requirements.txt
index dd5fa23..1214465 100644
--- a/python/sample-app-oauth/requirements.txt
+++ b/python/sample-app-oauth/requirements.txt
@@ -1,3 +1,3 @@
Flask>=2.0.0
python-dotenv>=0.19.0
-gohighlevel-api-client==1.0.0b1
+gohighlevel-api-client>=3.0.0
diff --git a/python/sample-app-oauth/templates/loading.html b/python/sample-app-oauth/templates/loading.html
new file mode 100644
index 0000000..44f0612
--- /dev/null
+++ b/python/sample-app-oauth/templates/loading.html
@@ -0,0 +1,51 @@
+
+
+
+
+
+ Finishing setupโฆ - GHL OAuth Example
+
+
+
+
+
+
+
diff --git a/python/sample-app-oauth/templates/show_data.html b/python/sample-app-oauth/templates/show_data.html
new file mode 100644
index 0000000..7ccc552
--- /dev/null
+++ b/python/sample-app-oauth/templates/show_data.html
@@ -0,0 +1,90 @@
+
+
+
+
+
+ SDK Data โ GHL OAuth Example
+
+
+
+
+
+
+ {% for section in sections %}
+
+
{{ section.label }}
+ {% if section.fields %}
+ {% for key, value in section.fields %}
+
+
{{ key }}
+
{{ value }}
+
+ {% endfor %}
+ {% elif section.error %}
+
โ ๏ธ {{ section.error }}
+ {% else %}
+
No data found
+ {% endif %}
+
+ {% endfor %}
+
+
+
+
+
diff --git a/python/sample-app-oauth/templates/token.html b/python/sample-app-oauth/templates/token.html
index c7c2938..069bd46 100644
--- a/python/sample-app-oauth/templates/token.html
+++ b/python/sample-app-oauth/templates/token.html
@@ -128,17 +128,17 @@
{% if token %}
- {% if token.access_token %}
+ {% if token.accessToken %}
Access Token
-
{{ token.access_token }}
+
{{ token.accessToken }}
{% endif %}
- {% if token.refresh_token %}
+ {% if token.refreshToken %}
Refresh Token
-
{{ token.refresh_token }}
+
{{ token.refreshToken }}
{% endif %}
@@ -164,9 +164,10 @@
{% endif %}
+ {% set resolved_location_id = location_id or token.locationId %}
{% else %}
diff --git a/python/sample-app-pit/requirements.txt b/python/sample-app-pit/requirements.txt
index d76bce2..459d9d3 100644
--- a/python/sample-app-pit/requirements.txt
+++ b/python/sample-app-pit/requirements.txt
@@ -2,4 +2,4 @@ fastapi>=0.104.0
uvicorn>=0.24.0
jinja2>=3.0.0
python-dotenv>=0.19.0
-gohighlevel-api-client==1.0.0b1
\ No newline at end of file
+gohighlevel-api-client>=3.0.0
\ No newline at end of file
diff --git a/python/sample-app-sql/app.py b/python/sample-app-sql/app.py
index dd1ecda..ace12b9 100644
--- a/python/sample-app-sql/app.py
+++ b/python/sample-app-sql/app.py
@@ -2,9 +2,10 @@
import asyncio
import threading
from concurrent.futures import ThreadPoolExecutor
-from flask import Flask, render_template, request, redirect, url_for
+from flask import Flask, render_template, request, redirect, url_for, jsonify
from dotenv import load_dotenv
from highlevel import HighLevel
+from highlevel.storage.interfaces import ISessionData
import traceback
from storage.sql_storage import SqlStorage
@@ -60,6 +61,8 @@ def run_async_in_loop(coro):
)
app = Flask(__name__)
+# Treat trailing-slash URLs (e.g. /install/) the same as /install.
+app.url_map.strict_slashes = False
def check_env():
"""Middleware to check environment variables"""
@@ -93,10 +96,12 @@ def index():
@app.route('/install')
def install():
redirect_uri = f"http://localhost:{PORT}/oauth-callback"
+ # oauth.readonly/oauth.write are needed to list installed locations and mint
+ # location tokens when the app is installed at the agency (company) level.
authorization_url = ghl.oauth.get_authorization_url(
CLIENT_ID,
redirect_uri,
- 'contacts.readonly contacts.write'
+ 'contacts.readonly contacts.write oauth.readonly oauth.write'
)
print('Redirect URL', authorization_url)
return redirect(authorization_url)
@@ -109,27 +114,121 @@ def oauth_callback():
async def async_oauth_operations():
access_token_data = await ghl.oauth.get_access_token({
- 'client_id': CLIENT_ID,
- 'client_secret': CLIENT_SECRET,
+ 'clientId': CLIENT_ID,
+ 'clientSecret': CLIENT_SECRET,
'code': code,
- 'grant_type': 'authorization_code',
+ 'grantType': 'authorization_code',
})
print('Token:', access_token_data)
-
- # Create ISessionData object from the OAuth response
- from highlevel.storage.interfaces import ISessionData
- session_data = ISessionData(access_token_data)
- await ghl.get_session_storage().set_session(access_token_data['locationId'], session_data)
return access_token_data
try:
access_token_data = run_async_in_loop(async_oauth_operations())
- return render_template('token.html', token=access_token_data, location_id=access_token_data['locationId'])
+ location_id = access_token_data.get('locationId')
+
+ if location_id:
+ run_async_in_loop(
+ ghl.get_session_storage().set_session(location_id, ISessionData(access_token_data))
+ )
+ return render_template('token.html', token=access_token_data, location_id=location_id)
+
+ # Company (agency) level install: store the company token and poll for a
+ # location token via the loading page.
+ company_id = access_token_data.get('companyId')
+ if not company_id:
+ return redirect(url_for('error_page', msg='Token response had neither locationId nor companyId'))
+
+ run_async_in_loop(
+ ghl.get_session_storage().set_session(company_id, ISessionData(access_token_data))
+ )
+ # Make the agency (company) token available to the agency-scoped polling calls
+ # directly via config (checked before storage). Cleared once a location resolves.
+ ghl.update_config({
+ 'agency_access_token': access_token_data.get('accessToken') or access_token_data.get('access_token')
+ })
+ return render_template('loading.html', company_id=company_id)
except Exception as err:
print('Error fetching token:', err)
traceback.print_exc()
return redirect(url_for('error_page', msg='Error fetching token'))
+@app.route('/install-locations')
+def install_locations():
+ """Poll endpoint: resolve a location token from the company token (JSON)."""
+ company_id = request.args.get('companyId')
+ if not company_id:
+ return jsonify({'ready': False, 'error': 'No companyId provided'})
+
+ async def resolve_location():
+ app_id = (CLIENT_ID or '').split('-')[0]
+ installed = await ghl.oauth.get_installed_location(
+ company_id=company_id,
+ app_id=app_id,
+ is_installed=True,
+ options={'headers': {'companyId': company_id}}
+ )
+ items = installed.get('items', []) if isinstance(installed, dict) else []
+
+ resolved_location_id = None
+ for item in items:
+ location_id = item.get('_id')
+ if not location_id:
+ continue
+
+ existing = await ghl.get_session_storage().get_session(location_id)
+ if not existing:
+ location_token = await ghl.oauth.get_location_access_token(
+ request_body={'companyId': company_id, 'locationId': location_id},
+ options={'headers': {'companyId': company_id}}
+ )
+ # The location-token response is camelCase; normalize the keys the
+ # SDK reads (access_token / refresh_token) before storing.
+ await ghl.get_session_storage().set_session(location_id, ISessionData({
+ **location_token,
+ 'access_token': location_token.get('accessToken'),
+ 'refresh_token': location_token.get('refreshToken'),
+ 'companyId': company_id,
+ 'locationId': location_id,
+ 'userType': 'Location',
+ }))
+
+ resolved_location_id = resolved_location_id or location_id
+
+ if resolved_location_id:
+ return {'ready': True, 'locationId': resolved_location_id}
+ return {'ready': False}
+
+ try:
+ result = run_async_in_loop(resolve_location())
+ if result.get('ready'):
+ # Location token resolved & stored; stop using the agency token so
+ # subsequent location-scoped calls (e.g. /contact) use the location token.
+ ghl.update_config({'agency_access_token': None})
+ return jsonify(result)
+ except Exception as err:
+ print('Error resolving location token:', err)
+ traceback.print_exc()
+ return jsonify({'ready': False, 'error': str(err)})
+
+@app.route('/oauth-result')
+def oauth_result():
+ """Show the resolved location token after the loading/polling step."""
+ company_id = request.args.get('companyId')
+ location_id = request.args.get('locationId')
+
+ async def load_session():
+ token = None
+ if location_id:
+ token = await ghl.get_session_storage().get_session(location_id)
+ if not token and company_id:
+ token = await ghl.get_session_storage().get_session(company_id)
+ return token
+
+ token = run_async_in_loop(load_session())
+ if not token:
+ return redirect(url_for('error_page', msg='No session found for the resolved location'))
+ return render_template('token.html', token=token, location_id=location_id)
+
@app.route('/contact')
def contact():
"""Handle contact retrieval - run async operation in event loop"""
@@ -145,18 +244,21 @@ async def async_contact_operations():
if not authorized:
return {'error': 'Please authorize the application to proceed'}
- contacts_data = await ghl.contacts.get_contacts(resource_id, None, None, None, 5)
- print('Fetched contacts:', contacts_data['contacts'])
+ search_result = await ghl.contacts.search_contacts_advanced(
+ request_body={'locationId': resource_id, 'pageLimit': 5},
+ options={'headers': {'locationId': resource_id}}
+ )
+ contacts = search_result.get('contacts', []) if isinstance(search_result, dict) else []
+ print('Fetched contacts:', contacts)
- contacts = contacts_data['contacts'] or []
if not contacts:
return {'error': 'No contacts found'}
contact_id = contacts[0]['id']
- if not contact_id:
- return {'error': 'No contact found'}
-
- contact_data = await ghl.contacts.get_contact(contact_id, { 'headers': { 'locationId': resource_id } })
+ contact_data = await ghl.contacts.get_contact(
+ contact_id,
+ options={'headers': {'locationId': resource_id}}
+ )
return {'contact': contact_data['contact']}
result = run_async_in_loop(async_contact_operations())
@@ -189,13 +291,9 @@ async def async_refresh_operations():
CLIENT_ID,
CLIENT_SECRET,
'refresh_token',
- token_details['userType']
+ token_details.get('userType', 'Location')
)
-
- # Create ISessionData object from the refresh response
- from highlevel.storage.interfaces import ISessionData
- session_data = ISessionData(refreshed_token)
- await ghl.get_session_storage().set_session(resource_id, session_data)
+ await ghl.get_session_storage().set_session(resource_id, ISessionData(refreshed_token))
return refreshed_token
result = run_async_in_loop(async_refresh_operations())
diff --git a/python/sample-app-sql/requirements.txt b/python/sample-app-sql/requirements.txt
index dd5fa23..1214465 100644
--- a/python/sample-app-sql/requirements.txt
+++ b/python/sample-app-sql/requirements.txt
@@ -1,3 +1,3 @@
Flask>=2.0.0
python-dotenv>=0.19.0
-gohighlevel-api-client==1.0.0b1
+gohighlevel-api-client>=3.0.0
diff --git a/python/sample-app-sql/storage/sql_storage.py b/python/sample-app-sql/storage/sql_storage.py
index 4c73599..c99bd07 100644
--- a/python/sample-app-sql/storage/sql_storage.py
+++ b/python/sample-app-sql/storage/sql_storage.py
@@ -57,6 +57,14 @@ def set_client_id(self, client_id: str) -> None:
"""Set the client ID"""
self.client_id = client_id
+ def _get_application_id(self) -> Optional[str]:
+ """Application id is the stable prefix of the client_id (before the '-').
+ Sessions are scoped to it so a client secret/suffix change does not orphan
+ them โ matching the memory and MongoDB storages."""
+ if not self.client_id:
+ return None
+ return self.client_id.split("-")[0]
+
async def init(self) -> None:
"""
Initialize database connection and create tables
@@ -78,7 +86,7 @@ async def init(self) -> None:
self.logger.debug('SqlStorage: Database connection established')
# Create default sessions table
- self.create_collection(self.default_collection)
+ await self.create_collection(self.default_collection)
except pymysql.Error as e:
self.logger.error(f'SqlStorage: Failed to connect to database: {e}')
@@ -147,6 +155,7 @@ async def set_session(self, resource_id: str, session_data: ISessionData) -> Non
Exception: If storage fails
"""
try:
+ session_data = self.normalize_session_data(session_data)
expire_at = None
if session_data.get('expires_in'):
expire_at = self._calculate_expire_at(session_data.get('expires_in'))
@@ -171,7 +180,7 @@ async def set_session(self, resource_id: str, session_data: ISessionData) -> Non
cursor.execute(sql, [
resource_id,
- self.client_id,
+ self._get_application_id(),
session_data.get('access_token'),
session_data.get('refresh_token'),
session_data.get('token_type'),
@@ -201,7 +210,7 @@ async def get_session(self, resource_id: str) -> Optional[ISessionData]:
try:
with self.client.cursor(pymysql.cursors.DictCursor) as cursor:
sql = f"SELECT * FROM `{self.default_collection}` WHERE resource_id = %s AND (client_id = %s OR client_id IS NULL)"
- cursor.execute(sql, [resource_id, self.client_id])
+ cursor.execute(sql, [resource_id, self._get_application_id()])
row = cursor.fetchone()
if not row:
@@ -236,7 +245,7 @@ async def delete_session(self, resource_id: str) -> None:
try:
with self.client.cursor() as cursor:
sql = f"DELETE FROM `{self.default_collection}` WHERE resource_id = %s AND (client_id = %s OR client_id IS NULL)"
- cursor.execute(sql, [resource_id, self.client_id])
+ cursor.execute(sql, [resource_id, self._get_application_id()])
self.logger.debug(f"SqlStorage: Session deleted for resource ID: {resource_id}")
@@ -256,7 +265,7 @@ async def get_access_token(self, resource_id: str) -> Optional[str]:
try:
with self.client.cursor() as cursor:
sql = f"SELECT access_token FROM `{self.default_collection}` WHERE resource_id = %s AND (client_id = %s OR client_id IS NULL)"
- cursor.execute(sql, [resource_id, self.client_id])
+ cursor.execute(sql, [resource_id, self._get_application_id()])
row = cursor.fetchone()
return row[0] if row else None
@@ -278,7 +287,7 @@ async def get_refresh_token(self, resource_id: str) -> Optional[str]:
try:
with self.client.cursor() as cursor:
sql = f"SELECT refresh_token FROM `{self.default_collection}` WHERE resource_id = %s AND (client_id = %s OR client_id IS NULL)"
- cursor.execute(sql, [resource_id, self.client_id])
+ cursor.execute(sql, [resource_id, self._get_application_id()])
row = cursor.fetchone()
return row[0] if row else None
@@ -297,7 +306,7 @@ async def get_sessions_by_application(self) -> List[ISessionData]:
try:
with self.client.cursor(pymysql.cursors.DictCursor) as cursor:
sql = f"SELECT * FROM `{self.default_collection}` WHERE client_id = %s"
- cursor.execute(sql, [self.client_id])
+ cursor.execute(sql, [self._get_application_id()])
rows = cursor.fetchall()
sessions = []
diff --git a/python/sample-app-sql/templates/loading.html b/python/sample-app-sql/templates/loading.html
new file mode 100644
index 0000000..44f0612
--- /dev/null
+++ b/python/sample-app-sql/templates/loading.html
@@ -0,0 +1,51 @@
+
+
+
+
+
+ Finishing setupโฆ - GHL OAuth Example
+
+
+
+
+
+
+
diff --git a/python/sample-app-sql/templates/token.html b/python/sample-app-sql/templates/token.html
index c7c2938..44b7df3 100644
--- a/python/sample-app-sql/templates/token.html
+++ b/python/sample-app-sql/templates/token.html
@@ -128,17 +128,17 @@
{% if token %}
- {% if token.access_token %}
+ {% if token.accessToken %}
Access Token
-
{{ token.access_token }}
+
{{ token.accessToken }}
{% endif %}
- {% if token.refresh_token %}
+ {% if token.refreshToken %}
Refresh Token
-
{{ token.refresh_token }}
+
{{ token.refreshToken }}
{% endif %}
@@ -164,9 +164,10 @@
{% endif %}
+ {% set resolved_location_id = location_id or token.locationId %}
{% else %}
diff --git a/python/sample-app-webhook/config/urls.py b/python/sample-app-webhook/config/urls.py
index 7837f34..9639f90 100644
--- a/python/sample-app-webhook/config/urls.py
+++ b/python/sample-app-webhook/config/urls.py
@@ -10,6 +10,8 @@
path('', views.index, name='index'),
path('install/', views.install, name='install'),
path('oauth-callback', views.oauth_callback, name='oauth_callback'),
+ path('install-locations', views.install_locations, name='install_locations'),
+ path('oauth-result', views.oauth_result, name='oauth_result'),
path('contact', views.contact, name='contact'),
path('refresh-token', views.refresh_token, name='refresh_token'),
path('api/ghl/webhook', views.webhook, name='webhook'),
diff --git a/python/sample-app-webhook/config/views.py b/python/sample-app-webhook/config/views.py
index 00464bd..5345f37 100644
--- a/python/sample-app-webhook/config/views.py
+++ b/python/sample-app-webhook/config/views.py
@@ -71,7 +71,7 @@ async def install(request):
authorization_url = ghl.oauth.get_authorization_url(
settings.CLIENT_ID,
redirect_uri,
- 'contacts.readonly contacts.write'
+ 'contacts.readonly contacts.write oauth.readonly oauth.write'
)
print('Redirect URL:', authorization_url)
return redirect(authorization_url)
@@ -87,23 +87,116 @@ async def oauth_callback(request):
if ghl is None:
await initialize_ghl()
access_token_data = await ghl.oauth.get_access_token({
- 'client_id': settings.CLIENT_ID,
- 'client_secret': settings.CLIENT_SECRET,
+ 'clientId': settings.CLIENT_ID,
+ 'clientSecret': settings.CLIENT_SECRET,
'code': code,
- 'grant_type': 'authorization_code',
+ 'grantType': 'authorization_code',
})
print('Token:', access_token_data)
- await ghl.get_session_storage().set_session(access_token_data['locationId'], access_token_data)
- return render(request, 'token.html', {
- 'token': access_token_data,
- 'location_id': access_token_data['locationId']
+ location_id = access_token_data.get('locationId')
+ if location_id:
+ await ghl.get_session_storage().set_session(location_id, access_token_data)
+ return render(request, 'token.html', {
+ 'token': access_token_data,
+ 'location_id': location_id
+ })
+
+ # Company (agency) level install: store the company token and poll for a
+ # location token via the loading page.
+ company_id = access_token_data.get('companyId')
+ if not company_id:
+ return redirect(reverse('error_page') + '?msg=Token response had neither locationId nor companyId')
+
+ await ghl.get_session_storage().set_session(company_id, access_token_data)
+ # Make the agency (company) token available to the agency-scoped polling calls
+ # directly via config (checked before storage). Cleared once a location resolves.
+ ghl.update_config({
+ 'agency_access_token': access_token_data.get('accessToken') or access_token_data.get('access_token')
})
+ return render(request, 'loading.html', {'company_id': company_id})
except Exception as err:
print('Error fetching token:', err)
traceback.print_exc()
return redirect(reverse('error_page') + f'?msg=Error fetching token: {str(err)}')
+async def install_locations(request):
+ """Poll endpoint: resolve a location token from the company token (JSON)."""
+ global ghl
+ if ghl is None:
+ await initialize_ghl()
+
+ company_id = request.GET.get('companyId')
+ if not company_id:
+ return JsonResponse({'ready': False, 'error': 'No companyId provided'})
+
+ try:
+ app_id = (settings.CLIENT_ID or '').split('-')[0]
+ installed = await ghl.oauth.get_installed_location(
+ company_id=company_id,
+ app_id=app_id,
+ is_installed=True,
+ options={'headers': {'companyId': company_id}}
+ )
+ items = installed.get('items', []) if isinstance(installed, dict) else []
+
+ resolved_location_id = None
+ for item in items:
+ location_id = item.get('_id')
+ if not location_id:
+ continue
+
+ existing = await ghl.get_session_storage().get_session(location_id)
+ if not existing:
+ location_token = await ghl.oauth.get_location_access_token(
+ request_body={'companyId': company_id, 'locationId': location_id},
+ options={'headers': {'companyId': company_id}}
+ )
+ # The location-token response is camelCase; normalize the keys the
+ # SDK reads (access_token / refresh_token) before storing.
+ await ghl.get_session_storage().set_session(location_id, {
+ **location_token,
+ 'access_token': location_token.get('accessToken'),
+ 'refresh_token': location_token.get('refreshToken'),
+ 'companyId': company_id,
+ 'locationId': location_id,
+ 'userType': 'Location',
+ })
+
+ resolved_location_id = resolved_location_id or location_id
+
+ if resolved_location_id:
+ # Stop using the agency token now that a location token is stored, so
+ # subsequent location-scoped calls (e.g. /contact) use the location token.
+ ghl.update_config({'agency_access_token': None})
+ return JsonResponse({'ready': True, 'locationId': resolved_location_id})
+ return JsonResponse({'ready': False})
+ except Exception as error:
+ print('Error resolving location token:', error)
+ traceback.print_exc()
+ return JsonResponse({'ready': False, 'error': str(error)})
+
+
+async def oauth_result(request):
+ """Show the resolved location token after the loading/polling step."""
+ global ghl
+ if ghl is None:
+ await initialize_ghl()
+
+ company_id = request.GET.get('companyId')
+ location_id = request.GET.get('locationId')
+
+ token = None
+ if location_id:
+ token = await ghl.get_session_storage().get_session(location_id)
+ if not token and company_id:
+ token = await ghl.get_session_storage().get_session(company_id)
+
+ if not token:
+ return redirect(reverse('error_page') + '?msg=No session found for the resolved location')
+ return render(request, 'token.html', {'token': token, 'location_id': location_id})
+
+
async def contact(request):
"""Handle contact retrieval"""
env_check = check_env(request)
@@ -123,18 +216,18 @@ async def contact(request):
global ghl
if ghl is None:
await initialize_ghl()
- contacts_data = await ghl.contacts.get_contacts(resource_id, None, None, None, 5)
- print('Fetched contacts:', contacts_data['contacts'])
+ search_result = await ghl.contacts.search_contacts_advanced(
+ request_body={'locationId': resource_id, 'pageLimit': 5},
+ options={'headers': {'locationId': resource_id}}
+ )
+ contacts = search_result.get('contacts', []) if isinstance(search_result, dict) else []
+ print('Fetched contacts:', contacts)
- contacts = contacts_data.get('contacts', [])
if not contacts:
return redirect(reverse('error_page') + '?msg=No contacts found')
contact_id = contacts[0]['id']
- if not contact_id:
- return redirect(reverse('error_page') + '?msg=No contact found')
-
- contact_data = await ghl.contacts.get_contact(contact_id, {'headers': {'locationId': resource_id}})
+ contact_data = await ghl.contacts.get_contact(contact_id, options={'headers': {'locationId': resource_id}})
return render(request, 'contact.html', {'contact': contact_data.get('contact')})
except Exception as error:
@@ -165,7 +258,7 @@ async def refresh_token(request):
settings.CLIENT_ID,
settings.CLIENT_SECRET,
'refresh_token',
- token_details.get('user_type', 'Location')
+ token_details.get('userType', 'Location')
)
await ghl.get_session_storage().set_session(resource_id, refreshed_token)
return render(request, 'token.html', {
@@ -193,6 +286,7 @@ async def webhook(request):
if getattr(request, 'is_signature_valid', False):
print('Signature valid...., processing webhook data...')
+ print('Signature type:', getattr(request, 'signature_type'))
return JsonResponse({
'status': 'success',
'message': 'Webhook processed successfully',
diff --git a/python/sample-app-webhook/requirements.txt b/python/sample-app-webhook/requirements.txt
index d66e45e..160bdc7 100644
--- a/python/sample-app-webhook/requirements.txt
+++ b/python/sample-app-webhook/requirements.txt
@@ -2,4 +2,4 @@ Django>=5.0.0
djangorestframework>=3.15.0
uvicorn>=0.24.0
python-dotenv>=0.19.0
-gohighlevel-api-client==1.0.0b1
\ No newline at end of file
+gohighlevel-api-client>=3.0.0
\ No newline at end of file
diff --git a/python/sample-app-webhook/templates/loading.html b/python/sample-app-webhook/templates/loading.html
new file mode 100644
index 0000000..44f0612
--- /dev/null
+++ b/python/sample-app-webhook/templates/loading.html
@@ -0,0 +1,51 @@
+
+
+
+
+
+ Finishing setupโฆ - GHL OAuth Example
+
+
+
+
+
+
+
diff --git a/python/sample-app-webhook/templates/token.html b/python/sample-app-webhook/templates/token.html
index c7c2938..ee43a1f 100644
--- a/python/sample-app-webhook/templates/token.html
+++ b/python/sample-app-webhook/templates/token.html
@@ -128,17 +128,17 @@
{% if token %}
- {% if token.access_token %}
+ {% if token.accessToken %}
Access Token
-
{{ token.access_token }}
+
{{ token.accessToken }}
{% endif %}
- {% if token.refresh_token %}
+ {% if token.refreshToken %}
Refresh Token
-
{{ token.refresh_token }}
+
{{ token.refreshToken }}
{% endif %}
@@ -164,11 +164,13 @@
{% endif %}
+ {% with resolved_location_id=location_id|default:token.locationId %}
+ {% endwith %}
{% else %}
No token information available