From 73e23c004c7fc10ccd0b469f8ad08b3198fc4f33 Mon Sep 17 00:00:00 2001 From: Karan Kaneria Date: Thu, 25 Jun 2026 11:50:42 +0530 Subject: [PATCH] feat: examples updated with latest v3 sdk for python --- python/sample-app-mongo/app.py | 130 +++++++-- python/sample-app-mongo/requirements.txt | 2 +- .../sample-app-mongo/templates/loading.html | 51 ++++ python/sample-app-mongo/templates/token.html | 13 +- python/sample-app-oauth/app.py | 258 +++++++++++++++++- python/sample-app-oauth/requirements.txt | 2 +- .../sample-app-oauth/templates/loading.html | 51 ++++ .../sample-app-oauth/templates/show_data.html | 90 ++++++ python/sample-app-oauth/templates/token.html | 13 +- python/sample-app-pit/requirements.txt | 2 +- python/sample-app-sql/app.py | 146 ++++++++-- python/sample-app-sql/requirements.txt | 2 +- python/sample-app-sql/storage/sql_storage.py | 23 +- python/sample-app-sql/templates/loading.html | 51 ++++ python/sample-app-sql/templates/token.html | 13 +- python/sample-app-webhook/config/urls.py | 2 + python/sample-app-webhook/config/views.py | 126 +++++++-- python/sample-app-webhook/requirements.txt | 2 +- .../sample-app-webhook/templates/loading.html | 51 ++++ .../sample-app-webhook/templates/token.html | 14 +- 20 files changed, 932 insertions(+), 110 deletions(-) create mode 100644 python/sample-app-mongo/templates/loading.html create mode 100644 python/sample-app-oauth/templates/loading.html create mode 100644 python/sample-app-oauth/templates/show_data.html create mode 100644 python/sample-app-sql/templates/loading.html create mode 100644 python/sample-app-webhook/templates/loading.html 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 %}
- ๐Ÿ‘ค Show Contact - ๐Ÿ”„ Refresh Token + ๐Ÿ‘ค Show Contact + ๐Ÿ”„ Refresh Token โ† Back to Home
{% 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 %} +
+ +
+ โ† Back to Home +
+ + 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 %}
- ๐Ÿ‘ค Show Contact - ๐Ÿ”„ Refresh Token + ๐Ÿ“Š Show Data + ๐Ÿ”„ Refresh Token โ† Back to Home
{% 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 %}
- ๐Ÿ‘ค Show Contact - ๐Ÿ”„ Refresh Token + ๐Ÿ‘ค Show Contact + ๐Ÿ”„ Refresh Token โ† Back to Home
{% 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 %}
- ๐Ÿ‘ค Show Contact - ๐Ÿ”„ Refresh Token + ๐Ÿ‘ค Show Contact + ๐Ÿ”„ Refresh Token โ† Back to Home
+ {% endwith %} {% else %}

No token information available